Skip to content

Commit 571f7ed

Browse files
committed
feat(ios): embed dynamic SwiftPM products with embed
A Swift package product declared `.library(type: .dynamic)` was linked into the target's Frameworks phase but never copied into the app bundle. Debug simulator builds still ran because Xcode adds an absolute LC_RPATH to the build directory's PackageFrameworks folder; device and archive builds crashed in dyld at launch with "Library not loaded: @rpath/<Product>.framework/<Product>". Xcode records a dynamic package product added through "Frameworks, Libraries, and Embedded Content" twice: once in the Frameworks phase and once in the "Embed Frameworks" copy phase with CodeSignOnCopy. The CLI only ever wrote the first entry. `IosSPMPackageBase` gains `embed?: string[]`, the subset of `libs` to copy into the bundle. For each, the pbxproj service writes a second PBXBuildFile for the same productRef into the target's own "Embed Frameworks" copy phase (dstSubfolderSpec 10), creating the phase when the target has none. Entries are keyed on productRef so re-applying on every prepare stays idempotent. An embed entry that is not in `libs` is warned about and skipped. The option is opt-in: the CLI cannot tell a static product from a dynamic one without parsing Package.swift, and embedding a static product's placeholder breaks the build. The existing post-build architecture strip already walks the bundle's Frameworks folder, so embedded package frameworks get the same treatment as plugin frameworks.
1 parent 95ec594 commit 571f7ed

4 files changed

Lines changed: 275 additions & 23 deletions

File tree

‎lib/definitions/ios.d.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ declare global {
5050
* extensions...) list their names here to link the package with them too.
5151
*/
5252
targets?: string[];
53+
/**
54+
* Products built as dynamic libraries (`.library(type: .dynamic)`) must
55+
* also be copied into the app bundle. Lists the subset of `libs` to
56+
* embed; a dynamic product that is linked but not embedded fails in dyld
57+
* at launch on devices.
58+
*/
59+
embed?: string[];
5360
}
5461

5562
/** A package resolved from a git remote at a version, range, branch or revision. */

‎lib/definitions/project.d.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ interface IOSSPMPackageBase {
111111
* you can list their names here to include the Swift Package with them
112112
*/
113113
targets?: string[];
114+
/**
115+
* Optional: products built as dynamic libraries (`.library(type: .dynamic)`)
116+
* must also be copied into the app bundle. List the subset of `libs` to
117+
* embed; a dynamic product that is linked but not embedded builds fine but
118+
* fails in dyld at launch on a device.
119+
*
120+
* A `.library(...)` without a type is static and is compiled into the app
121+
* executable, so it needs no embedding.
122+
*/
123+
embed?: string[];
114124
}
115125

116126
export interface IOSRemoteSPMPackage extends IOSSPMPackageBase {

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { IFileSystem } from "../../common/declarations";
1717
* 4. an entry in the target's Frameworks build phase, plus the target's
1818
* `packageProductDependencies`.
1919
*
20+
* A product listed in `embed` additionally gets a second `PBXBuildFile` in the
21+
* target's "Embed Frameworks" copy phase, which is how Xcode itself records a
22+
* dynamic package product added through "Frameworks, Libraries, and Embedded
23+
* Content" — linking alone leaves the framework out of the app bundle.
24+
*
2025
* Every entry is keyed by its pbxproj comment (e.g. `XCRemoteSwiftPackageReference
2126
* "Auth0"`), and an existing entry is updated in place rather than duplicated —
2227
* so applying the same set of packages repeatedly (which the CLI does on every
@@ -200,6 +205,26 @@ export class SPMPbxprojService implements ISPMPbxprojService {
200205
comment: packageReferenceComment,
201206
});
202207

208+
const embedLibs = new Set<string>();
209+
for (const lib of pkg.embed ?? []) {
210+
if (pkg.libs?.includes(lib)) {
211+
embedLibs.add(lib);
212+
} else {
213+
this.$logger.warn(
214+
`SPM: package "${pkg.name}" lists "${lib}" in embed but not in libs — only linked products can be embedded; skipping.`,
215+
);
216+
}
217+
}
218+
let embedBuildPhaseFiles: any[] | null = null;
219+
if (embedLibs.size) {
220+
const embedBuildPhaseObj = this.findOrCreateEmbedFrameworksBuildPhase(
221+
project,
222+
target,
223+
targetId,
224+
);
225+
embedBuildPhaseFiles = embedBuildPhaseObj["files"] ??= [];
226+
}
227+
203228
for (const lib of pkg.libs ?? []) {
204229
// The comment is just the product name, which two different packages
205230
// can share (e.g. both exposing a "Core" lib) — so entries here are
@@ -245,6 +270,29 @@ export class SPMPbxprojService implements ISPMPbxprojService {
245270
value: spmBuildFileUuid,
246271
comment: libComment,
247272
});
273+
274+
if (!embedLibs.has(lib)) {
275+
continue;
276+
}
277+
278+
const embedComment = `${lib} in Embed Frameworks`;
279+
const { uuid: spmEmbedBuildFileUuid } = this.addOrUpdateEntry(
280+
project,
281+
"PBXBuildFile",
282+
embedComment,
283+
{
284+
isa: "PBXBuildFile",
285+
productRef: spmProductDependencyUUID,
286+
productRef_comment: lib,
287+
settings: { ATTRIBUTES: ["CodeSignOnCopy"] },
288+
},
289+
(existing) => existing.productRef === spmProductDependencyUUID,
290+
);
291+
292+
this.addOrUpdateArrayEntry(embedBuildPhaseFiles, spmEmbedBuildFileUuid, {
293+
value: spmEmbedBuildFileUuid,
294+
comment: embedComment,
295+
});
248296
}
249297

250298
return true;
@@ -263,6 +311,41 @@ export class SPMPbxprojService implements ISPMPbxprojService {
263311
return null;
264312
}
265313

314+
/**
315+
* Finds the "Embed Frameworks" copy phase listed in this target's own
316+
* buildPhases, creating one when the target has none. Matched on the
317+
* destination (`dstSubfolderSpec` 10 is the bundle's Frameworks folder)
318+
* rather than the name alone, so a copy phase that merely shares the name
319+
* but copies elsewhere is not mistaken for it.
320+
*/
321+
private findOrCreateEmbedFrameworksBuildPhase(
322+
project: any,
323+
target: any,
324+
targetId: string,
325+
): any {
326+
const section =
327+
project.hash.project.objects["PBXCopyFilesBuildPhase"] ?? {};
328+
for (const phase of target.buildPhases ?? []) {
329+
const phaseObj = section[phase.value];
330+
if (
331+
phaseObj &&
332+
String(phaseObj.dstSubfolderSpec) === "10" &&
333+
(phaseObj.name === "Embed Frameworks" ||
334+
phaseObj.name === '"Embed Frameworks"')
335+
) {
336+
return phaseObj;
337+
}
338+
}
339+
340+
return project.addBuildPhase(
341+
[],
342+
"PBXCopyFilesBuildPhase",
343+
"Embed Frameworks",
344+
targetId,
345+
"frameworks",
346+
).buildPhase;
347+
}
348+
266349
/** Replaces a matching array entry in place, or appends it. */
267350
private addOrUpdateArrayEntry(
268351
array: any[],

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

Lines changed: 175 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const TARGET_NAME = "TNSBlank";
2121
// its PBXFrameworksBuildPhase uuid in that fixture (a group is also named
2222
// "Frameworks", so tests that strip the phase must key on the uuid)
2323
const FRAMEWORKS_PHASE_ID = "858B83F418CA22B800AB12DE";
24+
// its "Embed Frameworks" PBXCopyFilesBuildPhase uuid in that fixture
25+
const EMBED_PHASE_ID = "85F5BDFC1A9363BE006B9701";
2426

2527
const remotePackage: IosSPMPackage = {
2628
name: "swift-numerics",
@@ -35,6 +37,13 @@ const localPackage: IosSPMPackage = {
3537
path: "vendor/LocalPkg",
3638
};
3739

40+
const dynamicPackage: IosSPMPackage = {
41+
name: "DynamicPkg",
42+
libs: ["DynamicProduct", "StaticProduct"],
43+
embed: ["DynamicProduct"],
44+
path: "vendor/DynamicPkg",
45+
};
46+
3847
let warnings: string[] = [];
3948

4049
function createTestInjector(): IInjector {
@@ -74,6 +83,55 @@ function countOccurrences(contents: string, needle: string): number {
7483
return contents.split(needle).length - 1;
7584
}
7685

86+
/** Removes a build phase from the fixture target: its section entry and its slot in buildPhases. */
87+
function stripBuildPhase(projectRoot: string, phaseId: string, name: string) {
88+
const pbxPath = path.join(
89+
projectRoot,
90+
`${TARGET_NAME}.xcodeproj`,
91+
"project.pbxproj",
92+
);
93+
const stripped = readFileSync(pbxPath, "utf8")
94+
.replace(new RegExp(`^\\s*${phaseId} /\\* ${name} \\*/,\\n`, "m"), "")
95+
.replace(
96+
new RegExp(
97+
`^\\s*${phaseId} /\\* ${name} \\*/ = \\{[\\s\\S]*?\\};\\n`,
98+
"m",
99+
),
100+
"",
101+
);
102+
writeFileSync(pbxPath, stripped);
103+
}
104+
105+
function parsePbxproj(projectRoot: string): any {
106+
const xcode = require("nativescript-dev-xcode");
107+
const project = new xcode.project(
108+
path.join(projectRoot, `${TARGET_NAME}.xcodeproj`, "project.pbxproj"),
109+
);
110+
project.parseSync();
111+
return project;
112+
}
113+
114+
/** The uuids of the build files in the fixture target's "Embed Frameworks" phase. */
115+
function embedPhaseFileUuids(project: any): string[] {
116+
const targets = project.pbxNativeTargetSection();
117+
const target =
118+
targets[
119+
Object.keys(targets).find(
120+
(key) => !key.endsWith("_comment") && targets[key].name === TARGET_NAME,
121+
)
122+
];
123+
const copyPhases = project.hash.project.objects["PBXCopyFilesBuildPhase"];
124+
const embedPhases = (target.buildPhases as any[])
125+
.map((phase) => copyPhases[phase.value])
126+
.filter((phase) => phase && String(phase.dstSubfolderSpec) === "10");
127+
assert.lengthOf(
128+
embedPhases,
129+
1,
130+
"the target should have exactly one Embed Frameworks phase",
131+
);
132+
return (embedPhases[0].files ?? []).map((file: any) => file.value);
133+
}
134+
77135
describe("SPMPbxprojService", () => {
78136
let service: ISPMPbxprojService;
79137
let projectRoot: string;
@@ -246,29 +304,7 @@ describe("SPMPbxprojService", () => {
246304
});
247305

248306
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);
307+
stripBuildPhase(projectRoot, FRAMEWORKS_PHASE_ID, "Frameworks");
272308

273309
const result = service.addPackages(projectRoot, [
274310
{ targetName: TARGET_NAME, package: remotePackage },
@@ -331,6 +367,122 @@ describe("SPMPbxprojService", () => {
331367
);
332368
});
333369

370+
describe("embed", () => {
371+
it("copies an embedded product into the Embed Frameworks phase with CodeSignOnCopy, and still links it", () => {
372+
assert.isTrue(
373+
service.addPackages(projectRoot, [
374+
{ targetName: TARGET_NAME, package: dynamicPackage },
375+
]),
376+
);
377+
378+
const contents = readPbxproj(projectRoot);
379+
assert.include(contents, "DynamicProduct in Frameworks");
380+
assert.include(contents, "DynamicProduct in Embed Frameworks");
381+
assert.match(
382+
contents,
383+
/DynamicProduct in Embed Frameworks \*\/ = \{[^}]*settings = \{ATTRIBUTES = \(CodeSignOnCopy, \); \};/,
384+
"the embed build file should carry the CodeSignOnCopy attribute",
385+
);
386+
assert.include(contents, "StaticProduct in Frameworks");
387+
assert.notInclude(
388+
contents,
389+
"StaticProduct in Embed Frameworks",
390+
"a product not listed in embed must only be linked",
391+
);
392+
393+
const project = parsePbxproj(projectRoot);
394+
const buildFiles = project.hash.project.objects["PBXBuildFile"];
395+
const embeddedUuids = embedPhaseFileUuids(project);
396+
assert.lengthOf(embeddedUuids, 1);
397+
assert.equal(
398+
buildFiles[`${embeddedUuids[0]}_comment`],
399+
"DynamicProduct in Embed Frameworks",
400+
);
401+
assert.deepEqual(buildFiles[embeddedUuids[0]].settings, {
402+
ATTRIBUTES: ["CodeSignOnCopy"],
403+
});
404+
405+
// link and embed entries are distinct build files for the same product
406+
const productRefs = Object.keys(buildFiles)
407+
.filter((key) => !key.endsWith("_comment"))
408+
.map((key) => buildFiles[key])
409+
.filter((file) => file.productRef_comment === "DynamicProduct")
410+
.map((file) => file.productRef);
411+
assert.lengthOf(productRefs, 2);
412+
assert.equal(new Set(productRefs).size, 1);
413+
});
414+
415+
it("is idempotent — reapplying an embedded package does not duplicate the embed entry", () => {
416+
const assignments: IosSPMPackageAssignment[] = [
417+
{ targetName: TARGET_NAME, package: dynamicPackage },
418+
];
419+
420+
assert.isTrue(service.addPackages(projectRoot, assignments));
421+
const afterFirst = readPbxproj(projectRoot);
422+
assert.isTrue(service.addPackages(projectRoot, assignments));
423+
const afterSecond = readPbxproj(projectRoot);
424+
425+
assert.equal(afterSecond, afterFirst);
426+
assert.equal(
427+
countOccurrences(
428+
afterSecond,
429+
"DynamicProduct in Embed Frameworks */ = {",
430+
),
431+
1,
432+
);
433+
assert.lengthOf(embedPhaseFileUuids(parsePbxproj(projectRoot)), 1);
434+
});
435+
436+
it("creates the Embed Frameworks phase when the target has none", () => {
437+
stripBuildPhase(projectRoot, EMBED_PHASE_ID, "Embed Frameworks");
438+
assert.notInclude(readPbxproj(projectRoot), "Embed Frameworks");
439+
440+
assert.isTrue(
441+
service.addPackages(projectRoot, [
442+
{ targetName: TARGET_NAME, package: dynamicPackage },
443+
]),
444+
);
445+
446+
const contents = readPbxproj(projectRoot);
447+
assert.include(contents, 'name = "Embed Frameworks";');
448+
assert.include(contents, "dstSubfolderSpec = 10;");
449+
assert.lengthOf(embedPhaseFileUuids(parsePbxproj(projectRoot)), 1);
450+
});
451+
452+
it("does not create an Embed Frameworks phase for a package that embeds nothing", () => {
453+
stripBuildPhase(projectRoot, EMBED_PHASE_ID, "Embed Frameworks");
454+
455+
assert.isTrue(
456+
service.addPackages(projectRoot, [
457+
{ targetName: TARGET_NAME, package: remotePackage },
458+
]),
459+
);
460+
461+
assert.notInclude(readPbxproj(projectRoot), "Embed Frameworks");
462+
});
463+
464+
it("warns about an embed entry that is not a linked lib, and skips it", () => {
465+
assert.isTrue(
466+
service.addPackages(projectRoot, [
467+
{
468+
targetName: TARGET_NAME,
469+
package: { ...dynamicPackage, embed: ["NotLinked"] },
470+
},
471+
]),
472+
);
473+
474+
assert.isTrue(
475+
warnings.some(
476+
(w) => w.includes("NotLinked") && w.includes("not in libs"),
477+
),
478+
`expected a warning naming the unlinked product, got: ${warnings}`,
479+
);
480+
const contents = readPbxproj(projectRoot);
481+
assert.notInclude(contents, "in Embed Frameworks");
482+
assert.include(contents, "DynamicProduct in Frameworks");
483+
});
484+
});
485+
334486
it("quotes requirement values a pbxproj cannot hold bare", () => {
335487
assert.isTrue(
336488
service.addPackages(projectRoot, [

0 commit comments

Comments
 (0)