From 571f7ed2b6dd3a9f6d6ee4eaade2bc822f3f7443 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 8 Oct 2026 00:26:54 -0300 Subject: [PATCH] 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/.framework/". 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. --- lib/definitions/ios.d.ts | 7 + lib/definitions/project.d.ts | 10 ++ lib/services/ios/spm-pbxproj-service.ts | 83 ++++++++++ test/services/ios/spm-pbxproj-service.ts | 198 ++++++++++++++++++++--- 4 files changed, 275 insertions(+), 23 deletions(-) diff --git a/lib/definitions/ios.d.ts b/lib/definitions/ios.d.ts index 60c3430bbb..d37f6677e2 100644 --- a/lib/definitions/ios.d.ts +++ b/lib/definitions/ios.d.ts @@ -50,6 +50,13 @@ declare global { * extensions...) list their names here to link the package with them too. */ targets?: string[]; + /** + * Products built as dynamic libraries (`.library(type: .dynamic)`) must + * also be copied into the app bundle. Lists the subset of `libs` to + * embed; a dynamic product that is linked but not embedded fails in dyld + * at launch on devices. + */ + embed?: string[]; } /** A package resolved from a git remote at a version, range, branch or revision. */ diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..3fd9af6f76 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -111,6 +111,16 @@ interface IOSSPMPackageBase { * you can list their names here to include the Swift Package with them */ targets?: string[]; + /** + * Optional: products built as dynamic libraries (`.library(type: .dynamic)`) + * must also be copied into the app bundle. List the subset of `libs` to + * embed; a dynamic product that is linked but not embedded builds fine but + * fails in dyld at launch on a device. + * + * A `.library(...)` without a type is static and is compiled into the app + * executable, so it needs no embedding. + */ + embed?: string[]; } export interface IOSRemoteSPMPackage extends IOSSPMPackageBase { diff --git a/lib/services/ios/spm-pbxproj-service.ts b/lib/services/ios/spm-pbxproj-service.ts index 0ab41f868c..8ccc836042 100644 --- a/lib/services/ios/spm-pbxproj-service.ts +++ b/lib/services/ios/spm-pbxproj-service.ts @@ -17,6 +17,11 @@ import { IFileSystem } from "../../common/declarations"; * 4. an entry in the target's Frameworks build phase, plus the target's * `packageProductDependencies`. * + * A product listed in `embed` additionally gets a second `PBXBuildFile` in the + * target's "Embed Frameworks" copy phase, which is how Xcode itself records a + * dynamic package product added through "Frameworks, Libraries, and Embedded + * Content" — linking alone leaves the framework out of the app bundle. + * * Every entry is keyed by its pbxproj comment (e.g. `XCRemoteSwiftPackageReference * "Auth0"`), and an existing entry is updated in place rather than duplicated — * so applying the same set of packages repeatedly (which the CLI does on every @@ -200,6 +205,26 @@ export class SPMPbxprojService implements ISPMPbxprojService { comment: packageReferenceComment, }); + const embedLibs = new Set(); + for (const lib of pkg.embed ?? []) { + if (pkg.libs?.includes(lib)) { + embedLibs.add(lib); + } else { + this.$logger.warn( + `SPM: package "${pkg.name}" lists "${lib}" in embed but not in libs — only linked products can be embedded; skipping.`, + ); + } + } + let embedBuildPhaseFiles: any[] | null = null; + if (embedLibs.size) { + const embedBuildPhaseObj = this.findOrCreateEmbedFrameworksBuildPhase( + project, + target, + targetId, + ); + embedBuildPhaseFiles = embedBuildPhaseObj["files"] ??= []; + } + for (const lib of pkg.libs ?? []) { // The comment is just the product name, which two different packages // can share (e.g. both exposing a "Core" lib) — so entries here are @@ -245,6 +270,29 @@ export class SPMPbxprojService implements ISPMPbxprojService { value: spmBuildFileUuid, comment: libComment, }); + + if (!embedLibs.has(lib)) { + continue; + } + + const embedComment = `${lib} in Embed Frameworks`; + const { uuid: spmEmbedBuildFileUuid } = this.addOrUpdateEntry( + project, + "PBXBuildFile", + embedComment, + { + isa: "PBXBuildFile", + productRef: spmProductDependencyUUID, + productRef_comment: lib, + settings: { ATTRIBUTES: ["CodeSignOnCopy"] }, + }, + (existing) => existing.productRef === spmProductDependencyUUID, + ); + + this.addOrUpdateArrayEntry(embedBuildPhaseFiles, spmEmbedBuildFileUuid, { + value: spmEmbedBuildFileUuid, + comment: embedComment, + }); } return true; @@ -263,6 +311,41 @@ export class SPMPbxprojService implements ISPMPbxprojService { return null; } + /** + * Finds the "Embed Frameworks" copy phase listed in this target's own + * buildPhases, creating one when the target has none. Matched on the + * destination (`dstSubfolderSpec` 10 is the bundle's Frameworks folder) + * rather than the name alone, so a copy phase that merely shares the name + * but copies elsewhere is not mistaken for it. + */ + private findOrCreateEmbedFrameworksBuildPhase( + project: any, + target: any, + targetId: string, + ): any { + const section = + project.hash.project.objects["PBXCopyFilesBuildPhase"] ?? {}; + for (const phase of target.buildPhases ?? []) { + const phaseObj = section[phase.value]; + if ( + phaseObj && + String(phaseObj.dstSubfolderSpec) === "10" && + (phaseObj.name === "Embed Frameworks" || + phaseObj.name === '"Embed Frameworks"') + ) { + return phaseObj; + } + } + + return project.addBuildPhase( + [], + "PBXCopyFilesBuildPhase", + "Embed Frameworks", + targetId, + "frameworks", + ).buildPhase; + } + /** Replaces a matching array entry in place, or appends it. */ private addOrUpdateArrayEntry( array: any[], diff --git a/test/services/ios/spm-pbxproj-service.ts b/test/services/ios/spm-pbxproj-service.ts index 44a1f6a7eb..5f86a70666 100644 --- a/test/services/ios/spm-pbxproj-service.ts +++ b/test/services/ios/spm-pbxproj-service.ts @@ -21,6 +21,8 @@ const TARGET_NAME = "TNSBlank"; // its PBXFrameworksBuildPhase uuid in that fixture (a group is also named // "Frameworks", so tests that strip the phase must key on the uuid) const FRAMEWORKS_PHASE_ID = "858B83F418CA22B800AB12DE"; +// its "Embed Frameworks" PBXCopyFilesBuildPhase uuid in that fixture +const EMBED_PHASE_ID = "85F5BDFC1A9363BE006B9701"; const remotePackage: IosSPMPackage = { name: "swift-numerics", @@ -35,6 +37,13 @@ const localPackage: IosSPMPackage = { path: "vendor/LocalPkg", }; +const dynamicPackage: IosSPMPackage = { + name: "DynamicPkg", + libs: ["DynamicProduct", "StaticProduct"], + embed: ["DynamicProduct"], + path: "vendor/DynamicPkg", +}; + let warnings: string[] = []; function createTestInjector(): IInjector { @@ -74,6 +83,55 @@ function countOccurrences(contents: string, needle: string): number { return contents.split(needle).length - 1; } +/** Removes a build phase from the fixture target: its section entry and its slot in buildPhases. */ +function stripBuildPhase(projectRoot: string, phaseId: string, name: string) { + const pbxPath = path.join( + projectRoot, + `${TARGET_NAME}.xcodeproj`, + "project.pbxproj", + ); + const stripped = readFileSync(pbxPath, "utf8") + .replace(new RegExp(`^\\s*${phaseId} /\\* ${name} \\*/,\\n`, "m"), "") + .replace( + new RegExp( + `^\\s*${phaseId} /\\* ${name} \\*/ = \\{[\\s\\S]*?\\};\\n`, + "m", + ), + "", + ); + writeFileSync(pbxPath, stripped); +} + +function parsePbxproj(projectRoot: string): any { + const xcode = require("nativescript-dev-xcode"); + const project = new xcode.project( + path.join(projectRoot, `${TARGET_NAME}.xcodeproj`, "project.pbxproj"), + ); + project.parseSync(); + return project; +} + +/** The uuids of the build files in the fixture target's "Embed Frameworks" phase. */ +function embedPhaseFileUuids(project: any): string[] { + const targets = project.pbxNativeTargetSection(); + const target = + targets[ + Object.keys(targets).find( + (key) => !key.endsWith("_comment") && targets[key].name === TARGET_NAME, + ) + ]; + const copyPhases = project.hash.project.objects["PBXCopyFilesBuildPhase"]; + const embedPhases = (target.buildPhases as any[]) + .map((phase) => copyPhases[phase.value]) + .filter((phase) => phase && String(phase.dstSubfolderSpec) === "10"); + assert.lengthOf( + embedPhases, + 1, + "the target should have exactly one Embed Frameworks phase", + ); + return (embedPhases[0].files ?? []).map((file: any) => file.value); +} + describe("SPMPbxprojService", () => { let service: ISPMPbxprojService; let projectRoot: string; @@ -246,29 +304,7 @@ describe("SPMPbxprojService", () => { }); it("skips a target without a Frameworks build phase, warns, and writes nothing", () => { - // strip the Frameworks build phase from the fixture target — both the - // section entry and its slot in the target's buildPhases - const pbxPath = path.join( - projectRoot, - `${TARGET_NAME}.xcodeproj`, - "project.pbxproj", - ); - const stripped = readFileSync(pbxPath, "utf8") - .replace( - new RegExp( - `^\\s*${FRAMEWORKS_PHASE_ID} /\\* Frameworks \\*/,\\n`, - "m", - ), - "", - ) - .replace( - new RegExp( - `^\\s*${FRAMEWORKS_PHASE_ID} /\\* Frameworks \\*/ = \\{[\\s\\S]*?\\};\\n`, - "m", - ), - "", - ); - writeFileSync(pbxPath, stripped); + stripBuildPhase(projectRoot, FRAMEWORKS_PHASE_ID, "Frameworks"); const result = service.addPackages(projectRoot, [ { targetName: TARGET_NAME, package: remotePackage }, @@ -331,6 +367,122 @@ describe("SPMPbxprojService", () => { ); }); + describe("embed", () => { + it("copies an embedded product into the Embed Frameworks phase with CodeSignOnCopy, and still links it", () => { + assert.isTrue( + service.addPackages(projectRoot, [ + { targetName: TARGET_NAME, package: dynamicPackage }, + ]), + ); + + const contents = readPbxproj(projectRoot); + assert.include(contents, "DynamicProduct in Frameworks"); + assert.include(contents, "DynamicProduct in Embed Frameworks"); + assert.match( + contents, + /DynamicProduct in Embed Frameworks \*\/ = \{[^}]*settings = \{ATTRIBUTES = \(CodeSignOnCopy, \); \};/, + "the embed build file should carry the CodeSignOnCopy attribute", + ); + assert.include(contents, "StaticProduct in Frameworks"); + assert.notInclude( + contents, + "StaticProduct in Embed Frameworks", + "a product not listed in embed must only be linked", + ); + + const project = parsePbxproj(projectRoot); + const buildFiles = project.hash.project.objects["PBXBuildFile"]; + const embeddedUuids = embedPhaseFileUuids(project); + assert.lengthOf(embeddedUuids, 1); + assert.equal( + buildFiles[`${embeddedUuids[0]}_comment`], + "DynamicProduct in Embed Frameworks", + ); + assert.deepEqual(buildFiles[embeddedUuids[0]].settings, { + ATTRIBUTES: ["CodeSignOnCopy"], + }); + + // link and embed entries are distinct build files for the same product + const productRefs = Object.keys(buildFiles) + .filter((key) => !key.endsWith("_comment")) + .map((key) => buildFiles[key]) + .filter((file) => file.productRef_comment === "DynamicProduct") + .map((file) => file.productRef); + assert.lengthOf(productRefs, 2); + assert.equal(new Set(productRefs).size, 1); + }); + + it("is idempotent — reapplying an embedded package does not duplicate the embed entry", () => { + const assignments: IosSPMPackageAssignment[] = [ + { targetName: TARGET_NAME, package: dynamicPackage }, + ]; + + assert.isTrue(service.addPackages(projectRoot, assignments)); + const afterFirst = readPbxproj(projectRoot); + assert.isTrue(service.addPackages(projectRoot, assignments)); + const afterSecond = readPbxproj(projectRoot); + + assert.equal(afterSecond, afterFirst); + assert.equal( + countOccurrences( + afterSecond, + "DynamicProduct in Embed Frameworks */ = {", + ), + 1, + ); + assert.lengthOf(embedPhaseFileUuids(parsePbxproj(projectRoot)), 1); + }); + + it("creates the Embed Frameworks phase when the target has none", () => { + stripBuildPhase(projectRoot, EMBED_PHASE_ID, "Embed Frameworks"); + assert.notInclude(readPbxproj(projectRoot), "Embed Frameworks"); + + assert.isTrue( + service.addPackages(projectRoot, [ + { targetName: TARGET_NAME, package: dynamicPackage }, + ]), + ); + + const contents = readPbxproj(projectRoot); + assert.include(contents, 'name = "Embed Frameworks";'); + assert.include(contents, "dstSubfolderSpec = 10;"); + assert.lengthOf(embedPhaseFileUuids(parsePbxproj(projectRoot)), 1); + }); + + it("does not create an Embed Frameworks phase for a package that embeds nothing", () => { + stripBuildPhase(projectRoot, EMBED_PHASE_ID, "Embed Frameworks"); + + assert.isTrue( + service.addPackages(projectRoot, [ + { targetName: TARGET_NAME, package: remotePackage }, + ]), + ); + + assert.notInclude(readPbxproj(projectRoot), "Embed Frameworks"); + }); + + it("warns about an embed entry that is not a linked lib, and skips it", () => { + assert.isTrue( + service.addPackages(projectRoot, [ + { + targetName: TARGET_NAME, + package: { ...dynamicPackage, embed: ["NotLinked"] }, + }, + ]), + ); + + assert.isTrue( + warnings.some( + (w) => w.includes("NotLinked") && w.includes("not in libs"), + ), + `expected a warning naming the unlinked product, got: ${warnings}`, + ); + const contents = readPbxproj(projectRoot); + assert.notInclude(contents, "in Embed Frameworks"); + assert.include(contents, "DynamicProduct in Frameworks"); + }); + }); + it("quotes requirement values a pbxproj cannot hold bare", () => { assert.isTrue( service.addPackages(projectRoot, [