Skip to content

Commit d12ec93

Browse files
committed
chore: pr feedback
1 parent 037a05c commit d12ec93

2 files changed

Lines changed: 201 additions & 17 deletions

File tree

lib/services/ios/spm-pbxproj-service.ts

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/_comment$/, "")];
320+
return existing != null && matches(existing);
321+
});
280322
return commentKey ? commentKey.replace(/_comment$/, "") : 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-Za-z0-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+
352415
injector.register("spmPbxprojService", SPMPbxprojService);

test/services/ios/spm-pbxproj-service.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { assert } from "chai";
2-
import { mkdtempSync, mkdirSync, copyFileSync, readFileSync } from "fs";
2+
import {
3+
mkdtempSync,
4+
mkdirSync,
5+
copyFileSync,
6+
readFileSync,
7+
writeFileSync,
8+
} from "fs";
39
import { tmpdir } from "os";
410
import * as path from "path";
511
import { Yok } from "../../../lib/common/yok";
@@ -12,6 +18,9 @@ import { IInjector } from "../../../lib/common/definitions/yok";
1218

1319
// the target that exists in test/files/project.pbxproj
1420
const TARGET_NAME = "TNSBlank";
21+
// its PBXFrameworksBuildPhase uuid in that fixture (a group is also named
22+
// "Frameworks", so tests that strip the phase must key on the uuid)
23+
const FRAMEWORKS_PHASE_ID = "858B83F418CA22B800AB12DE";
1524

1625
const remotePackage: IosSPMPackage = {
1726
name: "swift-numerics",
@@ -236,6 +245,118 @@ describe("SPMPbxprojService", () => {
236245
assert.notInclude(contents, "version = 1.0.0");
237246
});
238247

248+
it("skips a target without a Frameworks build phase, warns, and writes nothing", () => {
249+
// strip the Frameworks build phase from the fixture target — both the
250+
// section entry and its slot in the target's buildPhases
251+
const pbxPath = path.join(
252+
projectRoot,
253+
`${TARGET_NAME}.xcodeproj`,
254+
"project.pbxproj",
255+
);
256+
const stripped = readFileSync(pbxPath, "utf8")
257+
.replace(
258+
new RegExp(
259+
`^\\s*${FRAMEWORKS_PHASE_ID} /\\* Frameworks \\*/,\\n`,
260+
"m",
261+
),
262+
"",
263+
)
264+
.replace(
265+
new RegExp(
266+
`^\\s*${FRAMEWORKS_PHASE_ID} /\\* Frameworks \\*/ = \\{[\\s\\S]*?\\};\\n`,
267+
"m",
268+
),
269+
"",
270+
);
271+
writeFileSync(pbxPath, stripped);
272+
273+
const result = service.addPackages(projectRoot, [
274+
{ targetName: TARGET_NAME, package: remotePackage },
275+
]);
276+
277+
assert.isFalse(
278+
result,
279+
"nothing could be applied, so nothing was written",
280+
);
281+
assert.isTrue(
282+
warnings.some((w) => w.includes("no Frameworks build phase")),
283+
`expected a warning about the missing build phase, got: ${warnings}`,
284+
);
285+
const contents = readPbxproj(projectRoot);
286+
assert.notInclude(contents, "XCRemoteSwiftPackageReference");
287+
assert.notInclude(
288+
contents,
289+
"packageReferences",
290+
"the skipped package must leave no trace, not even an empty list",
291+
);
292+
});
293+
294+
it("keeps same-named products from different packages distinct", () => {
295+
const otherPackage: IosSPMPackage = {
296+
name: "other-numerics",
297+
libs: ["RealModule"],
298+
repositoryURL: "https://example.com/other/other-numerics.git",
299+
version: "2.0.0",
300+
};
301+
const assignments: IosSPMPackageAssignment[] = [
302+
{ targetName: TARGET_NAME, package: remotePackage },
303+
{ targetName: TARGET_NAME, package: otherPackage },
304+
];
305+
306+
assert.isTrue(service.addPackages(projectRoot, assignments));
307+
// reapply to prove the scoped lookup is still idempotent
308+
assert.isTrue(service.addPackages(projectRoot, assignments));
309+
310+
const xcode = require("nativescript-dev-xcode");
311+
const project = new xcode.project(
312+
path.join(projectRoot, `${TARGET_NAME}.xcodeproj`, "project.pbxproj"),
313+
);
314+
project.parseSync();
315+
const section =
316+
project.hash.project.objects["XCSwiftPackageProductDependency"];
317+
const realModuleDeps = Object.keys(section)
318+
.filter((key) => !key.endsWith("_comment"))
319+
.map((key) => section[key])
320+
.filter((entry) => entry.productName === "RealModule");
321+
322+
assert.equal(
323+
realModuleDeps.length,
324+
2,
325+
"each package should own its own RealModule product dependency",
326+
);
327+
assert.equal(
328+
new Set(realModuleDeps.map((entry) => entry.package)).size,
329+
2,
330+
"the two product dependencies should point at different packages",
331+
);
332+
});
333+
334+
it("quotes requirement values a pbxproj cannot hold bare", () => {
335+
assert.isTrue(
336+
service.addPackages(projectRoot, [
337+
{
338+
targetName: TARGET_NAME,
339+
package: { ...remotePackage, version: "1.0.0-beta.1" },
340+
},
341+
]),
342+
);
343+
344+
assert.include(readPbxproj(projectRoot), 'version = "1.0.0-beta.1";');
345+
});
346+
347+
it("quotes branch requirements containing spaces", () => {
348+
assert.isTrue(
349+
service.addPackages(projectRoot, [
350+
{
351+
targetName: TARGET_NAME,
352+
package: { ...remotePackage, version: "release 1.0" },
353+
},
354+
]),
355+
);
356+
357+
assert.include(readPbxproj(projectRoot), 'branch = "release 1.0";');
358+
});
359+
239360
it("skips a package whose target is missing, and warns", () => {
240361
const result = service.addPackages(projectRoot, [
241362
{ targetName: "NoSuchTarget", package: remotePackage },

0 commit comments

Comments
 (0)