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, [