diff --git a/lib/definitions/ios.d.ts b/lib/definitions/ios.d.ts index 81b54c6896..43533d8d76 100644 --- a/lib/definitions/ios.d.ts +++ b/lib/definitions/ios.d.ts @@ -8,18 +8,18 @@ declare global { setupSigningForDevice( projectRoot: string, projectData: IProjectData, - buildConfig: IOSBuildData + buildConfig: IOSBuildData, ): Promise; setupSigningFromTeam( projectRoot: string, projectData: IProjectData, - teamId: string + teamId: string, ): Promise; setupSigningFromProvision( projectRoot: string, projectData: IProjectData, provision?: string, - mobileProvisionData?: any + mobileProvisionData?: any, ): Promise; } @@ -27,17 +27,17 @@ declare global { buildForSimulator( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise; buildForDevice( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise; buildForAppStore( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise; } @@ -47,35 +47,44 @@ declare global { applySPMPackages( platformData: IPlatformData, projectData: IProjectData, - pluginSpmPackages?: IosSPMPackage[] + pluginSpmPackages?: IosSPMPackage[], ); getSPMPackages( projectData: IProjectData, - platform: string + platform: string, ): IosSPMPackage[]; + resolveSPMDependencies( + platformData: IPlatformData, + projectData: IProjectData, + options?: { showProgress?: boolean }, + ): Promise; + ensureSPMDependenciesResolved( + platformData: IPlatformData, + projectData: IProjectData, + ): Promise; } interface IXcodebuildArgsService { getBuildForSimulatorArgs( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise; getBuildForDeviceArgs( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise; getXcodeProjectArgs( platformData: IPlatformData, - projectData: IProjectData + projectData: IProjectData, ): string[]; } interface IXcodebuildCommandService { executeCommand( args: string[], - options: IXcodebuildCommandOptions + options: IXcodebuildCommandOptions, ): Promise; } @@ -84,18 +93,24 @@ declare global { cwd: string; stdio?: string; spawnOptions?: any; + /** + * When provided, xcodebuild's output is piped (rather than inherited) and + * forwarded here so the caller can render its own progress UI (e.g. a + * spinner for Swift Package resolution/download activity). + */ + onProgress?: (chunk: { data: string; pipe: string }) => void; } interface IExportOptionsPlistService { createDevelopmentExportOptionsPlist( archivePath: string, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise; createDistributionExportOptionsPlist( projectRoot: string, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise; } diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 5f99aef0e5..6fd68c85e8 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -440,6 +440,14 @@ export class IOSProjectService ): Promise { const platformData = this.getPlatformData(projectData); + // On a first build, the runtime (and any other Swift packages) download + // here. Pre-resolve under a clear spinner so the subsequent + // "Xcode build..." step doesn't appear to hang while that happens. + await this.$spmService.ensureSPMDependenciesResolved( + platformData, + projectData, + ); + const handler = (data: any) => { this.emit(constants.BUILD_OUTPUT_EVENT_NAME, data); }; diff --git a/lib/services/ios/spm-service.ts b/lib/services/ios/spm-service.ts index c7462b9d8e..2ec1073e2b 100644 --- a/lib/services/ios/spm-service.ts +++ b/lib/services/ios/spm-service.ts @@ -2,12 +2,17 @@ import { injector } from "../../common/yok"; import { IProjectConfigService, IProjectData } from "../../definitions/project"; import { MobileProject } from "@nstudio/trapezedev-project"; import { IPlatformData } from "../../definitions/platform"; +import { IFileSystem } from "../../common/declarations"; +import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service"; +import { color } from "../../color"; import path = require("path"); export class SPMService implements ISPMService { constructor( private $logger: ILogger, + private $fs: IFileSystem, private $projectConfigService: IProjectConfigService, + private $terminalSpinnerService: ITerminalSpinnerService, private $xcodebuildCommandService: IXcodebuildCommandService, private $xcodebuildArgsService: IXcodebuildArgsService, ) {} @@ -36,11 +41,13 @@ export class SPMService implements ISPMService { ): void { // include swift packages from plugin configs // but allow app packages to override plugin packages with the same name - const appPackageNames = new Set(appPackages.map(pkg => pkg.name)); - + const appPackageNames = new Set(appPackages.map((pkg) => pkg.name)); + for (const pluginPkg of pluginPackages) { if (appPackageNames.has(pluginPkg.name)) { - this.$logger.trace(`SPM: app package overrides plugin package: ${pluginPkg.name}`); + this.$logger.trace( + `SPM: app package overrides plugin package: ${pluginPkg.name}`, + ); } else { appPackages.push(pluginPkg); } @@ -106,29 +113,226 @@ export class SPMService implements ISPMService { await project.commit(); // finally resolve the dependencies - await this.resolveSPMDependencies(platformData, projectData); + await this.resolveSPMDependencies(platformData, projectData, { + showProgress: true, + }); } catch (err) { + // best-effort, but don't bury the failure below trace level — a red + // resolve spinner with no visible reason is confusing. Warn with the + // message, keep the full error at trace. + this.$logger.warn( + `Failed to apply Swift Package dependencies: ${err?.message ?? err}`, + ); this.$logger.trace("SPM: error applying SPM packages: ", err); } } + /** + * Resolves (downloads + pins) the Swift Package dependencies referenced by + * the Xcode project. On a first build this is where the NativeScript runtime + * (now distributed as a remote Swift package) and any other SPM packages are + * actually downloaded — which can take a while. Pass `showProgress` to render + * a live spinner so the CLI doesn't look stalled while that happens. + * + * In verbose mode (`--log trace`) the condensed spinner is bypassed and + * xcodebuild's raw resolution log is streamed straight through instead. + */ public async resolveSPMDependencies( platformData: IPlatformData, projectData: IProjectData, + options?: { showProgress?: boolean }, ) { - await this.$xcodebuildCommandService.executeCommand( - this.$xcodebuildArgsService - .getXcodeProjectArgs(platformData, projectData) - .concat([ - "-destination", - "generic/platform=iOS", - "-resolvePackageDependencies", - ]), - { + const args = this.$xcodebuildArgsService + .getXcodeProjectArgs(platformData, projectData) + .concat([ + "-destination", + "generic/platform=iOS", + "-resolvePackageDependencies", + ]); + + // Without progress, or when verbose: let xcodebuild's own resolution log + // stream straight to the terminal (inherited stdio). Verbose users want + // the raw log, not a condensed spinner that hides it. + if (!options?.showProgress || this.$logger.isVerbose()) { + await this.$xcodebuildCommandService.executeCommand(args, { + cwd: projectData.projectDir, + message: "Resolving Swift Package dependencies...", + }); + return; + } + + const spinner = this.$terminalSpinnerService.createSpinner(); + const startedAt = Date.now(); + let activity = "Resolving Swift Package dependencies"; + let lineBuffer = ""; + + const render = () => { + const elapsed = Math.round((Date.now() - startedAt) / 1000); + spinner.text = `${activity}… ${color.dim(`(${elapsed}s)`)}`; + }; + // keep the elapsed timer ticking even when xcodebuild is silent (e.g. + // while a binary artifact downloads) so the user can see it's alive. + const ticker = setInterval(render, 1000); + + const onProgress = (chunk: { data: string; pipe: string }) => { + lineBuffer += chunk.data; + const lines = lineBuffer.split("\n"); + // keep the last (possibly partial) line in the buffer + lineBuffer = lines.pop(); + for (const line of lines) { + const described = this.describeSPMActivity(line); + if (described) { + activity = described; + render(); + } + } + }; + + render(); + spinner.start(); + try { + await this.$xcodebuildCommandService.executeCommand(args, { cwd: projectData.projectDir, - message: "Resolving SPM dependencies...", - }, + onProgress, + }); + spinner.succeed(color.green("Swift Package dependencies resolved")); + } catch (err) { + spinner.fail(color.red("Failed to resolve Swift Package dependencies")); + throw err; + } finally { + clearInterval(ticker); + } + } + + /** + * Best-effort pre-resolve before a build so the (potentially slow) first-time + * Swift package download happens under a clear progress indicator instead of + * silently inside the subsequent "Xcode build..." step. No-op when the + * project has no SPM references or they're already resolved. + */ + public async ensureSPMDependenciesResolved( + platformData: IPlatformData, + projectData: IProjectData, + ) { + if (!this.hasSPMReferences(platformData, projectData)) { + this.$logger.trace("SPM: project has no Swift Package references."); + return; + } + + if (this.arePackagesResolved(platformData, projectData)) { + this.$logger.trace( + "SPM: Swift Package dependencies already resolved; skipping pre-resolve.", + ); + return; + } + + try { + await this.resolveSPMDependencies(platformData, projectData, { + showProgress: true, + }); + } catch (err) { + // non-fatal: the build itself will resolve packages and surface the + // authoritative error if something is genuinely wrong. + this.$logger.trace("SPM: pre-resolve failed (continuing): ", err); + } + } + + /** + * Maps a raw xcodebuild/SwiftPM resolution log line to a concise, + * user-facing activity description (or null for lines we don't surface). + */ + private describeSPMActivity(line: string): string | null { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + + // the big, otherwise-silent wait on a first build: a binary artifact + // (the NativeScript runtime xcframework) downloading. + if (/Downloading binary artifact/i.test(trimmed)) { + if (/ios-spm|nativescript/i.test(trimmed)) { + return "Downloading the NativeScript runtime (first build only)"; + } + return "Downloading Swift Package binaries (first build only)"; + } + if (/^Fetching\b/i.test(trimmed)) { + return `Fetching ${this.shortenPackageRef(trimmed)}`; + } + if (/^Cloning\b/i.test(trimmed)) { + return `Cloning ${this.shortenPackageRef(trimmed)}`; + } + if (/Computing version for/i.test(trimmed)) { + return "Computing package versions"; + } + if (/Resolve Package Graph/i.test(trimmed)) { + return "Resolving Swift Package graph"; + } + if (/Resolved source packages/i.test(trimmed)) { + return "Finalizing Swift Package dependencies"; + } + return null; + } + + /** Extracts a short, readable name from a SwiftPM repo URL/log line. */ + private shortenPackageRef(line: string): string { + const match = line.match(/https?:\/\/\S+/); + if (!match) { + return "Swift Packages"; + } + return path + .basename(match[0]) + .replace(/\.git$/, "") + .replace(/[)\s].*$/, ""); + } + + /** True when the Xcode project references any Swift packages. */ + private hasSPMReferences( + platformData: IPlatformData, + projectData: IProjectData, + ): boolean { + const pbxprojPath = path.join( + platformData.projectRoot, + `${projectData.projectName}.xcodeproj`, + "project.pbxproj", ); + if (!this.$fs.exists(pbxprojPath)) { + return false; + } + const contents = this.$fs.readText(pbxprojPath); + return ( + contents.includes("XCRemoteSwiftPackageReference") || + contents.includes("XCLocalSwiftPackageReference") || + contents.includes("packageReferences") + ); + } + + /** + * True when a Package.resolved already exists for the project — i.e. packages + * have been resolved at least once, so the build's own resolve step will be a + * fast no-op rather than a slow, silent first-time download. + */ + private arePackagesResolved( + platformData: IPlatformData, + projectData: IProjectData, + ): boolean { + const candidates = [ + path.join( + platformData.projectRoot, + `${projectData.projectName}.xcworkspace`, + "xcshareddata", + "swiftpm", + "Package.resolved", + ), + path.join( + platformData.projectRoot, + `${projectData.projectName}.xcodeproj`, + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved", + ), + ]; + return candidates.some((p) => this.$fs.exists(p)); } } injector.register("spmService", SPMService); diff --git a/lib/services/ios/xcodebuild-command-service.ts b/lib/services/ios/xcodebuild-command-service.ts index f5a1f7a6af..4b1b994295 100644 --- a/lib/services/ios/xcodebuild-command-service.ts +++ b/lib/services/ios/xcodebuild-command-service.ts @@ -10,22 +10,46 @@ export class XcodebuildCommandService implements IXcodebuildCommandService { constructor( private $childProcess: IChildProcess, private $errors: IErrors, - private $logger: ILogger + private $logger: ILogger, ) {} public async executeCommand( args: string[], options: { cwd: string; - stdio: string; + stdio?: string; message?: string; spawnOptions?: any; - } + // When provided, xcodebuild's output is piped (rather than inherited) + // and forwarded here line-by-line so the caller can render its own + // progress UI (e.g. a spinner for SPM resolution/download activity). + onProgress?: (chunk: { data: string; pipe: string }) => void; + }, ): Promise { - const { message, cwd, stdio, spawnOptions } = options; - this.$logger.info(message || "Xcode build..."); + const { message, cwd, stdio, spawnOptions, onProgress } = options; + + // A caller rendering its own progress UI owns stdout, so skip the + // default "Xcode build..." line that would otherwise clobber it. + if (!onProgress) { + this.$logger.info(message || "Xcode build..."); + } - const childProcessOptions = { cwd, stdio: stdio || "inherit" }; + const childProcessOptions = { + cwd, + stdio: onProgress ? "pipe" : stdio || "inherit", + }; + + let detachProgress: () => void; + if (onProgress) { + const handler = (chunk: { data: string; pipe: string }) => + onProgress(chunk); + this.$childProcess.on(constants.BUILD_OUTPUT_EVENT_NAME, handler); + detachProgress = () => + this.$childProcess.removeListener( + constants.BUILD_OUTPUT_EVENT_NAME, + handler, + ); + } try { const commandResult = await this.$childProcess.spawnFromEvent( @@ -36,12 +60,14 @@ export class XcodebuildCommandService implements IXcodebuildCommandService { spawnOptions || { emitOptions: { eventName: constants.BUILD_OUTPUT_EVENT_NAME }, throwError: true, - } + }, ); return commandResult; } catch (err) { this.$errors.fail(err.message); + } finally { + detachProgress?.(); } } } diff --git a/test/ios-project-service.ts b/test/ios-project-service.ts index 1816ce789b..20decc96d1 100644 --- a/test/ios-project-service.ts +++ b/test/ios-project-service.ts @@ -241,6 +241,7 @@ function createTestInjector( testInjector.register("tempService", TempServiceStub); testInjector.register("spmService", { applySPMPackages: () => Promise.resolve(), + ensureSPMDependenciesResolved: () => Promise.resolve(), }); return testInjector; diff --git a/test/spm-service.ts b/test/spm-service.ts index b0ccfeff19..829b23812f 100644 --- a/test/spm-service.ts +++ b/test/spm-service.ts @@ -1,4 +1,5 @@ import { assert } from "chai"; +import { SPMService } from "../lib/services/ios/spm-service"; /** * Helper function to merge app and plugin SPM packages. @@ -6,14 +7,14 @@ import { assert } from "chai"; */ function mergeSPMPackages(appPackages: any[], pluginPackages: any[]): any[] { const spmPackages = [...appPackages]; - const appPackageNames = new Set(spmPackages.map(pkg => pkg.name)); - + const appPackageNames = new Set(spmPackages.map((pkg) => pkg.name)); + for (const pluginPkg of pluginPackages) { if (!appPackageNames.has(pluginPkg.name)) { spmPackages.push(pluginPkg); } } - + return spmPackages; } @@ -50,7 +51,9 @@ describe("SPM Service - Package Override Logic", () => { // Verify the result assert.equal(spmPackages.length, 2, "Should have 2 packages total"); - const firebasePackage = spmPackages.find((pkg) => pkg.name === "FirebaseCore"); + const firebasePackage = spmPackages.find( + (pkg) => pkg.name === "FirebaseCore", + ); assert.isDefined(firebasePackage, "Should include FirebaseCore package"); assert.equal( firebasePackage.version, @@ -58,9 +61,18 @@ describe("SPM Service - Package Override Logic", () => { "Should use app's FirebaseCore version (10.0.0), not plugin's (9.0.0)", ); - const alamofirePackage = spmPackages.find((pkg) => pkg.name === "Alamofire"); - assert.isDefined(alamofirePackage, "Should include Alamofire package from plugin"); - assert.equal(alamofirePackage.version, "5.0.0", "Should use plugin's Alamofire version"); + const alamofirePackage = spmPackages.find( + (pkg) => pkg.name === "Alamofire", + ); + assert.isDefined( + alamofirePackage, + "Should include Alamofire package from plugin", + ); + assert.equal( + alamofirePackage.version, + "5.0.0", + "Should use plugin's Alamofire version", + ); }); it("should include all plugin packages when no app packages exist", () => { @@ -84,10 +96,18 @@ describe("SPM Service - Package Override Logic", () => { const spmPackages = mergeSPMPackages(appPackages, pluginPackages); // Verify the result - assert.equal(spmPackages.length, 2, "Should include both plugin packages"); + assert.equal( + spmPackages.length, + 2, + "Should include both plugin packages", + ); const packageNames = spmPackages.map((pkg) => pkg.name); - assert.include(packageNames, "FirebaseCore", "Should include FirebaseCore"); + assert.include( + packageNames, + "FirebaseCore", + "Should include FirebaseCore", + ); assert.include(packageNames, "Alamofire", "Should include Alamofire"); }); @@ -159,3 +179,118 @@ describe("SPM Service - Package Override Logic", () => { }); }); }); + +describe("SPM Service - resolution log parsing", () => { + // describeSPMActivity / shortenPackageRef are pure helpers with no runtime + // dependencies, so we exercise the real implementation directly (the + // constructor only stashes injected services it never touches here). + const service: any = new (SPMService as any)(); + + describe("describeSPMActivity", () => { + it("flags the NativeScript runtime binary download specifically", () => { + assert.equal( + service.describeSPMActivity( + "Downloading binary artifact https://github.com/NativeScript/ios-spm/releases/download/9.0.3/NativeScript.xcframework.zip", + ), + "Downloading the NativeScript runtime (first build only)", + ); + }); + + it("flags other binary artifact downloads generically", () => { + assert.equal( + service.describeSPMActivity( + "Downloading binary artifact https://example.com/SomeSDK.xcframework.zip", + ), + "Downloading Swift Package binaries (first build only)", + ); + }); + + it("summarizes fetching with the package name", () => { + assert.equal( + service.describeSPMActivity( + "Fetching from https://github.com/NativeScript/ios-spm.git", + ), + "Fetching ios-spm", + ); + }); + + it("summarizes cloning with the package name", () => { + assert.equal( + service.describeSPMActivity( + "Cloning https://github.com/Alamofire/Alamofire.git", + ), + "Cloning Alamofire", + ); + }); + + it("recognizes version computation", () => { + assert.equal( + service.describeSPMActivity( + "Computing version for https://github.com/NativeScript/ios-spm.git", + ), + "Computing package versions", + ); + }); + + it("recognizes the package graph resolution start", () => { + assert.equal( + service.describeSPMActivity("Resolve Package Graph"), + "Resolving Swift Package graph", + ); + }); + + it("recognizes the resolved/finalize step", () => { + assert.equal( + service.describeSPMActivity("Resolved source packages:"), + "Finalizing Swift Package dependencies", + ); + }); + + it("tolerates leading/trailing whitespace", () => { + assert.equal( + service.describeSPMActivity( + " Fetching https://github.com/NativeScript/ios-spm.git ", + ), + "Fetching ios-spm", + ); + }); + + it("returns null for blank lines", () => { + assert.isNull(service.describeSPMActivity("")); + assert.isNull(service.describeSPMActivity(" ")); + }); + + it("returns null for unrelated build output", () => { + assert.isNull( + service.describeSPMActivity("CompileSwift normal arm64 Foo.swift"), + ); + }); + }); + + describe("shortenPackageRef", () => { + it("extracts the repo name and strips the .git suffix", () => { + assert.equal( + service.shortenPackageRef( + "Fetching from https://github.com/NativeScript/ios-spm.git", + ), + "ios-spm", + ); + }); + + it("handles URLs without a .git suffix", () => { + assert.equal( + service.shortenPackageRef( + "Cloning https://github.com/Alamofire/Alamofire", + ), + "Alamofire", + ); + }); + + it("falls back to a generic label when there is no URL", () => { + assert.equal( + service.shortenPackageRef("Fetching cached package"), + "Swift Packages", + ); + }); + }); +});