@@ -6,8 +6,29 @@ import { IFileSystem } from "../../common/declarations";
66import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service" ;
77import { color } from "../../color" ;
88import path = require( "path" ) ;
9+ import os = require( "os" ) ;
10+ import fs = require( "fs" ) ;
911
1012export 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 ( / ^ F e t c h i n g \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 ,
0 commit comments