Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/definitions/ios.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
10 changes: 10 additions & 0 deletions lib/definitions/project.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
83 changes: 83 additions & 0 deletions lib/services/ios/spm-pbxproj-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -200,6 +205,26 @@ export class SPMPbxprojService implements ISPMPbxprojService {
comment: packageReferenceComment,
});

const embedLibs = new Set<string>();
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
Expand Down Expand Up @@ -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;
Expand All @@ -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[],
Expand Down
198 changes: 175 additions & 23 deletions test/services/ios/spm-pbxproj-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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, [
Expand Down
Loading