Skip to content

Commit 3f5461f

Browse files
authored
feat(ios): improve spm build logs, clarity and size reporting (#6085)
1 parent 7a2efaf commit 3f5461f

3 files changed

Lines changed: 263 additions & 5 deletions

File tree

lib/services/ios-project-service.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,6 +1336,30 @@ export class IOSProjectService
13361336
[],
13371337
);
13381338
if (packages.length) {
1339+
for (const pkg of packages) {
1340+
// a plugin's local package path is naturally authored relative
1341+
// to the plugin itself, but the SPM service resolves relative
1342+
// paths against the app project dir. When the app-relative
1343+
// path doesn't exist (e.g. non-hoisted node_modules layouts),
1344+
// fall back to resolving against the plugin's own directory.
1345+
if (
1346+
"path" in pkg &&
1347+
pkg.path &&
1348+
!path.isAbsolute(pkg.path) &&
1349+
!this.$fs.exists(path.resolve(projectData.projectDir, pkg.path))
1350+
) {
1351+
const pluginRelativePath = path.resolve(
1352+
plugin.fullPath,
1353+
pkg.path,
1354+
);
1355+
if (this.$fs.exists(pluginRelativePath)) {
1356+
this.$logger.trace(
1357+
`SPM: resolved plugin-relative package path for ${pkg.name}: ${pluginRelativePath}`,
1358+
);
1359+
pkg.path = pluginRelativePath;
1360+
}
1361+
}
1362+
}
13391363
pluginSpmPackages.push(...packages);
13401364
}
13411365
}
@@ -1505,9 +1529,15 @@ export class IOSProjectService
15051529
): Promise<void> {
15061530
const project = this.createPbxProj(projectData);
15071531
const group = await this.getRootGroup(groupName, sourceFolderPath);
1532+
// pin the sources to the main app target: without an explicit target the
1533+
// underlying xcode lib picks whichever "Sources" build phase it finds
1534+
// first, which can be an extension target (e.g. a widget) once one
1535+
// exists — compiling plugin native code into extensions breaks their
1536+
// builds (and bloats them) since they lack the app's search paths.
15081537
project.addPbxGroup(group.files, group.name, group.path, null, {
15091538
isMain: true,
15101539
filesRelativeToProject: true,
1540+
target: project.getFirstTarget()?.uuid,
15111541
});
15121542
project.addToHeaderSearchPaths(group.path);
15131543
const headerFiles = this.$fs.exists(sourceFolderPath)

lib/services/ios/spm-service.ts

Lines changed: 212 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,29 @@ import { IFileSystem } from "../../common/declarations";
66
import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service";
77
import { color } from "../../color";
88
import path = require("path");
9+
import os = require("os");
10+
import fs = require("fs");
911

1012
export class SPMService implements ISPMService {
13+
// SwiftPM keeps one bare clone per source package here (shared by Xcode and
14+
// xcodebuild). Watching a package's clone grow is the only visibility into
15+
// an otherwise silent long fetch — SwiftPM clones a repository's ENTIRE git
16+
// history to read Package.swift, so a package hosted in a multi-GB repo can
17+
// legitimately "fetch" for tens of minutes with no output.
18+
private static readonly SWIFTPM_REPO_CACHE = path.join(
19+
os.homedir(),
20+
"Library",
21+
"Caches",
22+
"org.swift.swiftpm",
23+
"repositories",
24+
);
25+
// Once a single package's clone passes this size, surface a one-time note
26+
// explaining why the fetch is slow (250 MB is already far beyond any
27+
// reasonably-hosted Swift package).
28+
private static readonly LARGE_CLONE_NOTE_BYTES = 250 * 1024 * 1024;
29+
// Lines of raw xcodebuild output kept for the failure report.
30+
private static readonly OUTPUT_TAIL_LINES = 25;
31+
1132
constructor(
1233
private $logger: ILogger,
1334
private $fs: IFileSystem,
@@ -42,13 +63,22 @@ export class SPMService implements ISPMService {
4263
// include swift packages from plugin configs
4364
// but allow app packages to override plugin packages with the same name
4465
const appPackageNames = new Set(appPackages.map((pkg) => pkg.name));
66+
// multiple plugins may declare the same package (e.g. a shared shim) —
67+
// only the first declaration is added; a second same-name package would
68+
// produce duplicate (and possibly conflicting) references in the pbxproj.
69+
const addedPluginPackageNames = new Set<string>();
4570

4671
for (const pluginPkg of pluginPackages) {
4772
if (appPackageNames.has(pluginPkg.name)) {
4873
this.$logger.trace(
4974
`SPM: app package overrides plugin package: ${pluginPkg.name}`,
5075
);
76+
} else if (addedPluginPackageNames.has(pluginPkg.name)) {
77+
this.$logger.trace(
78+
`SPM: skipping duplicate plugin package: ${pluginPkg.name}`,
79+
);
5180
} else {
81+
addedPluginPackageNames.add(pluginPkg.name);
5282
appPackages.push(pluginPkg);
5383
}
5484
}
@@ -79,6 +109,11 @@ export class SPMService implements ISPMService {
79109
return;
80110
}
81111

112+
// name every package and where it comes from — when resolution is
113+
// slow or fails, this is the first thing needed to tell WHICH
114+
// dependency is responsible.
115+
this.$logger.info(this.formatPackageListing(spmPackages));
116+
82117
const project = new MobileProject(platformData.projectRoot, {
83118
ios: {
84119
path: ".",
@@ -99,6 +134,13 @@ export class SPMService implements ISPMService {
99134
// resolve the path relative to the project root
100135
this.$logger.trace("SPM: resolving path for package: ", pkg.path);
101136
pkg.path = path.resolve(projectData.projectDir, pkg.path);
137+
if (!this.$fs.exists(pkg.path)) {
138+
// surface this now — otherwise the only symptom is a cryptic
139+
// xcodebuild resolution failure much later.
140+
this.$logger.warn(
141+
`SPM: local package path for "${pkg.name}" does not exist: ${pkg.path} — Xcode will fail to resolve it.`,
142+
);
143+
}
102144
}
103145
this.$logger.trace(`SPM: adding package ${pkg.name} to project.`, pkg);
104146
await project.ios.addSPMPackage(projectData.projectName, pkg);
@@ -165,24 +207,80 @@ export class SPMService implements ISPMService {
165207
const startedAt = Date.now();
166208
let activity = "Resolving Swift Package dependencies";
167209
let lineBuffer = "";
210+
// package currently being git-fetched (short name), if any — used to
211+
// measure its growing clone in the SwiftPM cache so a long silent fetch
212+
// shows visible progress ("2.31 GB of git history fetched") instead of
213+
// looking hung.
214+
let fetchingPackageRef: string = null;
215+
let fetchedBytes = 0;
216+
let largeCloneNoted = false;
217+
// rolling tail of raw xcodebuild output for the failure report.
218+
const outputTail: string[] = [];
168219

169220
const render = () => {
170221
const elapsed = Math.round((Date.now() - startedAt) / 1000);
171-
spinner.text = `${activity}${color.dim(`(${this.formatElapsed(elapsed)})`)}`;
222+
const fetched =
223+
fetchedBytes > 0
224+
? ` — ${this.formatBytes(fetchedBytes)} of git history fetched`
225+
: "";
226+
spinner.text = `${activity}${fetched} ${color.dim(`(${this.formatElapsed(elapsed)})`)}`;
172227
};
173228
// keep the elapsed timer ticking even when xcodebuild is silent (e.g.
174-
// while a binary artifact downloads) so the user can see it's alive.
175-
const ticker = setInterval(render, 1000);
229+
// while a repository clones or a binary artifact downloads) so the user
230+
// can see it's alive. Every 5th tick, measure the in-progress clone.
231+
let tickCount = 0;
232+
const ticker = setInterval(() => {
233+
tickCount++;
234+
if (fetchingPackageRef && tickCount % 5 === 0) {
235+
fetchedBytes = this.getPackageCloneSizeBytes(fetchingPackageRef);
236+
if (
237+
!largeCloneNoted &&
238+
fetchedBytes >= SPMService.LARGE_CLONE_NOTE_BYTES
239+
) {
240+
largeCloneNoted = true;
241+
// persist a one-time explanation above the spinner: this is the
242+
// point where users otherwise assume the CLI is stuck.
243+
spinner.stopAndPersist({
244+
symbol: color.yellow("ℹ"),
245+
text: color.yellow(
246+
`the "${fetchingPackageRef}" package is hosted in a repository with a large git history — ` +
247+
`SwiftPM clones the entire repository on first fetch, which can take a long time.${os.EOL}` +
248+
` cache: ${SPMService.SWIFTPM_REPO_CACHE}`,
249+
),
250+
});
251+
spinner.start();
252+
}
253+
}
254+
render();
255+
}, 1000);
176256

177257
const onProgress = (chunk: { data: string; pipe: string }) => {
178258
lineBuffer += chunk.data;
179-
const lines = lineBuffer.split("\n");
259+
// tolerate CRLF as well as LF so parsed lines never carry a stray \r
260+
const lines = lineBuffer.split(/\r?\n/);
180261
// keep the last (possibly partial) line in the buffer
181262
lineBuffer = lines.pop();
182263
for (const line of lines) {
264+
const trimmed = line.trim();
265+
if (trimmed) {
266+
this.$logger.trace(`SPM: ${trimmed}`);
267+
outputTail.push(trimmed);
268+
if (outputTail.length > SPMService.OUTPUT_TAIL_LINES) {
269+
outputTail.shift();
270+
}
271+
}
183272
const described = this.describeSPMActivity(line);
184273
if (described) {
185274
activity = described;
275+
// track which package a "Fetching <url>" line refers to so the
276+
// ticker can measure its clone; any other activity means the
277+
// fetch finished.
278+
if (/^Fetching\b/i.test(trimmed)) {
279+
fetchingPackageRef = this.shortenPackageRef(trimmed);
280+
} else {
281+
fetchingPackageRef = null;
282+
fetchedBytes = 0;
283+
}
186284
render();
187285
}
188286
}
@@ -195,9 +293,21 @@ export class SPMService implements ISPMService {
195293
cwd: projectData.projectDir,
196294
onProgress,
197295
});
198-
spinner.succeed(color.green("Swift Package dependencies resolved"));
296+
const elapsed = Math.round((Date.now() - startedAt) / 1000);
297+
spinner.succeed(
298+
color.green("Swift Package dependencies resolved") +
299+
color.dim(` (${this.formatElapsed(elapsed)})`),
300+
);
199301
} catch (err) {
200302
spinner.fail(color.red("Failed to resolve Swift Package dependencies"));
303+
// the spinner swallowed the raw log — replay the tail so the actual
304+
// xcodebuild error is visible without rerunning in verbose mode.
305+
if (outputTail.length) {
306+
this.$logger.info(color.dim("xcodebuild output (last lines):"));
307+
for (const line of outputTail) {
308+
this.$logger.info(color.dim(` ${line}`));
309+
}
310+
}
201311
throw err;
202312
} finally {
203313
clearInterval(ticker);
@@ -306,6 +416,103 @@ export class SPMService implements ISPMService {
306416
return `${minutes}m ${seconds}s`;
307417
}
308418

419+
/**
420+
* Multi-line listing of every package and its source — one package per
421+
* line, separated by the platform EOL so entries never clump together in
422+
* terminal output on macOS, Windows, or Linux.
423+
*/
424+
private formatPackageListing(spmPackages: IosSPMPackage[]): string {
425+
return [
426+
"Swift Packages:",
427+
...spmPackages.map((pkg) => ` ${this.describePackageSource(pkg)}`),
428+
].join(os.EOL);
429+
}
430+
431+
/**
432+
* One-line description of a package and where it resolves from, e.g.
433+
* "FontManager (1.0.12 · https://github.com/NativeScript/font-manager.git)"
434+
* or "CanvasNative (local: node_modules/@nativescript/canvas/platforms/ios/NativeScriptV8)".
435+
*/
436+
private describePackageSource(pkg: IosSPMPackage): string {
437+
if ("path" in pkg) {
438+
return `${pkg.name} (local: ${pkg.path})`;
439+
}
440+
return `${pkg.name} (${pkg.version} · ${pkg.repositoryURL})`;
441+
}
442+
443+
/**
444+
* Size on disk of the SwiftPM cache clone(s) for a package ref (the short
445+
* name produced by shortenPackageRef). Cache entries are named
446+
* "<repo-name>-<hash>". Returns 0 when nothing is there (yet).
447+
*/
448+
private getPackageCloneSizeBytes(packageRef: string): number {
449+
let entries: fs.Dirent[];
450+
try {
451+
entries = fs.readdirSync(SPMService.SWIFTPM_REPO_CACHE, {
452+
withFileTypes: true,
453+
});
454+
} catch (err) {
455+
return 0;
456+
}
457+
let total = 0;
458+
for (const entry of entries) {
459+
if (
460+
entry.isDirectory() &&
461+
(entry.name === packageRef || entry.name.startsWith(`${packageRef}-`))
462+
) {
463+
total += this.getDirectorySizeBytes(
464+
path.join(SPMService.SWIFTPM_REPO_CACHE, entry.name),
465+
);
466+
}
467+
}
468+
return total;
469+
}
470+
471+
/**
472+
* Recursive directory size via the raw fs API. Tolerates files vanishing
473+
* mid-walk (git renames its temp packfiles while cloning) and does not
474+
* follow symlinks.
475+
*/
476+
private getDirectorySizeBytes(dirPath: string): number {
477+
let total = 0;
478+
const pending = [dirPath];
479+
while (pending.length) {
480+
const current = pending.pop();
481+
let entries: fs.Dirent[];
482+
try {
483+
entries = fs.readdirSync(current, { withFileTypes: true });
484+
} catch (err) {
485+
continue;
486+
}
487+
for (const entry of entries) {
488+
const fullPath = path.join(current, entry.name);
489+
try {
490+
if (entry.isDirectory()) {
491+
pending.push(fullPath);
492+
} else if (entry.isFile()) {
493+
total += fs.statSync(fullPath).size;
494+
}
495+
} catch (err) {
496+
// entry disappeared between readdir and stat — ignore
497+
}
498+
}
499+
}
500+
return total;
501+
}
502+
503+
/** Formats a byte count as a short human-readable size ("2.31 GB"). */
504+
private formatBytes(bytes: number): string {
505+
const GB = 1024 ** 3;
506+
const MB = 1024 ** 2;
507+
if (bytes >= GB) {
508+
return `${(bytes / GB).toFixed(2)} GB`;
509+
}
510+
if (bytes >= MB) {
511+
return `${Math.round(bytes / MB)} MB`;
512+
}
513+
return `${Math.round(bytes / 1024)} KB`;
514+
}
515+
309516
/** True when the Xcode project references any Swift packages. */
310517
private hasSPMReferences(
311518
platformData: IPlatformData,

test/spm-service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { assert } from "chai";
2+
import { EOL } from "os";
23
import { SPMService } from "../lib/services/ios/spm-service";
34

45
/**
@@ -307,4 +308,24 @@ describe("SPM Service - resolution log parsing", () => {
307308
assert.equal(service.formatElapsed(3725), "62m 5s");
308309
});
309310
});
311+
312+
describe("formatPackageListing", () => {
313+
it("lists one package per line using the platform EOL", () => {
314+
assert.equal(
315+
service.formatPackageListing([
316+
{
317+
name: "FontManager",
318+
version: "1.0.12",
319+
repositoryURL: "https://github.com/NativeScript/font-manager.git",
320+
},
321+
{ name: "CanvasNative", path: "node_modules/canvas/ios" },
322+
]),
323+
"Swift Packages:" +
324+
EOL +
325+
" FontManager (1.0.12 · https://github.com/NativeScript/font-manager.git)" +
326+
EOL +
327+
" CanvasNative (local: node_modules/canvas/ios)",
328+
);
329+
});
330+
});
310331
});

0 commit comments

Comments
 (0)