@@ -65,8 +65,9 @@ export class SPMPbxprojService implements ISPMPbxprojService {
6565 continue ;
6666 }
6767
68- this . addPackageToTarget ( project , targetId , pkg , projectRoot ) ;
69- added = true ;
68+ if ( this . addPackageToTarget ( project , targetId , pkg , projectRoot ) ) {
69+ added = true ;
70+ }
7071 }
7172
7273 if ( ! added ) {
@@ -123,29 +124,38 @@ export class SPMPbxprojService implements ISPMPbxprojService {
123124 return null ;
124125 }
125126
127+ /** Returns true when the package was actually linked into the target. */
126128 private addPackageToTarget (
127129 project : any ,
128130 targetId : string ,
129131 pkg : IosSPMPackage ,
130132 projectRoot : string ,
131- ) : void {
133+ ) : boolean {
132134 const target = project . pbxNativeTargetSection ( ) [ targetId ] ;
133- const firstProject = project . getFirstProject ( ) . firstProject ;
134- const packageReferences : any [ ] = ( firstProject [ "packageReferences" ] ??= [ ] ) ;
135- const packageProductReferences : any [ ] = ( target [
136- "packageProductDependencies"
137- ] ??= [ ] ) ;
138135
139136 // A target without a Frameworks build phase has nowhere to link the
140137 // products; adding the package reference alone would leave the project
141- // in a state Xcode reports as corrupt, so bail out loudly instead.
142- const frameworkBuildPhaseObj = project . pbxFrameworksBuildPhaseObj ( targetId ) ;
138+ // in a state Xcode reports as corrupt, so bail out loudly instead —
139+ // before touching the project, so a skipped package leaves no trace.
140+ // (Resolved from the target's own buildPhases: the xcode lib's
141+ // pbxFrameworksBuildPhaseObj falls back to *any* target's Frameworks
142+ // phase when this one has none, which would link into the wrong target.)
143+ const frameworkBuildPhaseObj = this . findFrameworksBuildPhase (
144+ project ,
145+ target ,
146+ ) ;
143147 if ( ! frameworkBuildPhaseObj ) {
144148 this . $logger . warn (
145149 `SPM: target for package "${ pkg . name } " has no Frameworks build phase — skipping.` ,
146150 ) ;
147- return ;
151+ return false ;
148152 }
153+
154+ const firstProject = project . getFirstProject ( ) . firstProject ;
155+ const packageReferences : any [ ] = ( firstProject [ "packageReferences" ] ??= [ ] ) ;
156+ const packageProductReferences : any [ ] = ( target [
157+ "packageProductDependencies"
158+ ] ??= [ ] ) ;
149159 const frameworkBuildPhaseFiles : any [ ] = ( frameworkBuildPhaseObj [ "files" ] ??=
150160 [ ] ) ;
151161
@@ -171,7 +181,7 @@ export class SPMPbxprojService implements ISPMPbxprojService {
171181 packageReferenceSectionContent = {
172182 isa : packageReferenceSection ,
173183 repositoryURL : JSON . stringify ( pkg . repositoryURL ) ,
174- requirement : classifyVersion ( pkg . version ) ,
184+ requirement : quoteValuesForPbxproj ( classifyVersion ( pkg . version ) ) ,
175185 } ;
176186 }
177187
@@ -191,6 +201,10 @@ export class SPMPbxprojService implements ISPMPbxprojService {
191201 } ) ;
192202
193203 for ( const lib of pkg . libs ?? [ ] ) {
204+ // The comment is just the product name, which two different packages
205+ // can share (e.g. both exposing a "Core" lib) — so entries here are
206+ // additionally matched on the package they belong to, otherwise the
207+ // second package would silently repoint the first one's entries.
194208 const { uuid : spmProductDependencyUUID } = this . addOrUpdateEntry (
195209 project ,
196210 "XCSwiftPackageProductDependency" ,
@@ -201,6 +215,7 @@ export class SPMPbxprojService implements ISPMPbxprojService {
201215 package_comment : spmPackageReferenceComment ,
202216 productName : lib ,
203217 } ,
218+ ( existing ) => existing . package === spmPackageReferenceUUID ,
204219 ) ;
205220
206221 const libComment = `${ lib } in Frameworks` ;
@@ -214,6 +229,7 @@ export class SPMPbxprojService implements ISPMPbxprojService {
214229 productRef : spmProductDependencyUUID ,
215230 productRef_comment : lib ,
216231 } ,
232+ ( existing ) => existing . productRef === spmProductDependencyUUID ,
217233 ) ;
218234
219235 this . addOrUpdateArrayEntry (
@@ -230,6 +246,21 @@ export class SPMPbxprojService implements ISPMPbxprojService {
230246 comment : libComment ,
231247 } ) ;
232248 }
249+
250+ return true ;
251+ }
252+
253+ /** Finds the Frameworks build phase listed in this target's own buildPhases. */
254+ private findFrameworksBuildPhase ( project : any , target : any ) : any | null {
255+ const section =
256+ project . hash . project . objects [ "PBXFrameworksBuildPhase" ] ?? { } ;
257+ for ( const phase of target . buildPhases ?? [ ] ) {
258+ const phaseObj = section [ phase . value ] ;
259+ if ( phaseObj ) {
260+ return phaseObj ;
261+ }
262+ }
263+ return null ;
233264 }
234265
235266 /** Replaces a matching array entry in place, or appends it. */
@@ -250,16 +281,19 @@ export class SPMPbxprojService implements ISPMPbxprojService {
250281 * Writes an object into a pbxproj section, reusing the uuid of an entry
251282 * with the same comment when one is already present. The comment is the
252283 * identity of an entry here — it's what keeps repeated applies idempotent.
284+ * When the comment alone is ambiguous (product names are not unique across
285+ * packages), `matches` narrows the lookup to the right entry.
253286 */
254287 private addOrUpdateEntry (
255288 project : any ,
256289 section : string ,
257290 entryComment : string ,
258291 entry : any ,
292+ matches ?: ( existing : any ) => boolean ,
259293 ) : { uuid : string ; comment : string } {
260294 const pbxSection = ( project . hash . project . objects [ section ] ??= { } ) ;
261295 const entryUuid =
262- this . findUuidByComment ( project , section , entryComment ) ??
296+ this . findUuidByComment ( project , section , entryComment , matches ) ??
263297 project . generateUuid ( ) ;
264298
265299 pbxSection [ `${ entryUuid } _comment` ] = entryComment ;
@@ -272,11 +306,19 @@ export class SPMPbxprojService implements ISPMPbxprojService {
272306 project : any ,
273307 section : string ,
274308 comment : string ,
309+ matches ?: ( existing : any ) => boolean ,
275310 ) : string | null {
276311 const pbxSection = project . hash . project . objects [ section ] ?? { } ;
277- const commentKey = Object . keys ( pbxSection ) . find (
278- ( key ) => key . endsWith ( "_comment" ) && pbxSection [ key ] === comment ,
279- ) ;
312+ const commentKey = Object . keys ( pbxSection ) . find ( ( key ) => {
313+ if ( ! key . endsWith ( "_comment" ) || pbxSection [ key ] !== comment ) {
314+ return false ;
315+ }
316+ if ( ! matches ) {
317+ return true ;
318+ }
319+ const existing = pbxSection [ key . replace ( / _ c o m m e n t $ / , "" ) ] ;
320+ return existing != null && matches ( existing ) ;
321+ } ) ;
280322 return commentKey ? commentKey . replace ( / _ c o m m e n t $ / , "" ) : null ;
281323 }
282324}
@@ -349,4 +391,25 @@ export function classifyVersion(version: string): Record<string, string> {
349391 } ;
350392}
351393
394+ /**
395+ * The charset Xcode itself leaves unquoted in a pbxproj. The pbxproj writer
396+ * emits values verbatim, so anything outside it — a prerelease version like
397+ * "1.0.0-beta.1", a branch like "release 1.0" — must be quoted by the caller
398+ * or the written file is malformed.
399+ */
400+ const UNQUOTED_PBX_VALUE = / ^ [ A - Z a - z 0 - 9 _ $ . / ] + $ / ;
401+
402+ function quoteValuesForPbxproj (
403+ obj : Record < string , string > ,
404+ ) : Record < string , string > {
405+ const result : Record < string , string > = { } ;
406+ for ( const [ key , value ] of Object . entries ( obj ) ) {
407+ result [ key ] =
408+ typeof value === "string" && ! UNQUOTED_PBX_VALUE . test ( value )
409+ ? JSON . stringify ( value )
410+ : value ;
411+ }
412+ return result ;
413+ }
414+
352415injector . register ( "spmPbxprojService" , SPMPbxprojService ) ;
0 commit comments