fix: give the widget and the notification extension their own copy of the shared keychain code so pod install succeeds

This commit is contained in:
Linus Rath
2026-10-31 16:49:09 +01:00
parent 22893d327f
commit 05bc0f567b
5 changed files with 374 additions and 21 deletions
+10 -11
View File
@@ -33,6 +33,7 @@ const {
withDangerousMod,
createRunOncePlugin,
} = require('@expo/config-plugins');
const { addTargetFiles, hasTarget } = require('../ios-target-files');
const TARGET = 'NotificationService';
const DEPLOYMENT_TARGET = '15.1';
@@ -222,25 +223,23 @@ function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
function addExtensionTarget(cfg, { teamId }) {
const project = cfg.modResults;
if (project.pbxTargetByName(TARGET)) return;
if (hasTarget(project, TARGET)) return;
// addTarget expects these sections; a fresh Expo project may not have them.
const objects = project.hash.project.objects;
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
const group = project.addPbxGroup(
[...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`],
TARGET,
TARGET,
);
// Hang the group off the project's main group.
const mainGroupId = project.getFirstProject().firstProject.mainGroup;
project.addToPbxGroup(group.uuid, mainGroupId);
const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`;
const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId);
project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
// Its own group and file references: the app and the widget compile
// copies of some of these files (plugins/ios-target-files.js).
addTargetFiles(project, {
name: TARGET,
targetUuid: target.uuid,
sources: SOURCE_FILES,
files: [`${TARGET}-Info.plist`, `${TARGET}.entitlements`],
});
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
+85
View File
@@ -0,0 +1,85 @@
// Gives an extension target (plugins/ios-push, plugins/ios-widget) its own
// group of files in the Xcode project, and compiles its sources in its own
// Sources build phase.
//
// Written against the project objects directly: the `xcode` package's
// addPbxGroup and addBuildPhase reuse any file reference or build file with
// the same path, so two extensions that each carry SharedKeychain.swift ended
// up sharing one file reference (in both groups) and one build file (in both
// Sources phases). CocoaPods' xcodeproj then fails to save the project in
// pod install ("Consistency issue: no parent for object SharedKeychain.swift").
const FILE_TYPES = {
swift: 'sourcecode.swift',
m: 'sourcecode.c.objc',
h: 'sourcecode.c.h',
plist: 'text.plist.xml',
entitlements: 'text.plist.entitlements',
};
const quote = (value) => `"${value}"`;
const unquote = (value) => (typeof value === 'string' ? value.replace(/^"(.*)"$/, '$1') : value);
/**
* Whether the project has a target `name`. pbxTargetByName misses targets
* that addTarget made: it stores the name in quotes, so prebuild without
* --clean added the extension targets a second time.
*/
function hasTarget(project, name) {
return Object.entries(project.pbxNativeTargetSection() ?? {}).some(
([key, target]) => !key.endsWith('_comment') && unquote(target.name) === name,
);
}
/**
* Adds a group `name` (folder ios/<name>) under the project's main group,
* with a new file reference for each of `files` and `sources`, and a Sources
* build phase on the target that compiles `sources` from that group.
*
* @param project the `xcode` project (cfg.modResults of withXcodeProject)
* @param {{ name: string, targetUuid: string, sources: string[], files?: string[] }} options
* files: other files to show in the group (Info.plist, entitlements), not compiled
* @returns {{ groupUuid: string, phaseUuid: string }}
*/
function addTargetFiles(project, { name, targetUuid, sources, files = [] }) {
const objects = project.hash.project.objects;
objects.PBXFileReference = objects.PBXFileReference || {};
objects.PBXBuildFile = objects.PBXBuildFile || {};
objects.PBXGroup = objects.PBXGroup || {};
const groupUuid = project.generateUuid();
const group = { isa: 'PBXGroup', children: [], name: quote(name), path: quote(name), sourceTree: '"<group>"' };
objects.PBXGroup[groupUuid] = group;
objects.PBXGroup[`${groupUuid}_comment`] = name;
const mainGroup = objects.PBXGroup[project.getFirstProject().firstProject.mainGroup];
mainGroup.children.push({ value: groupUuid, comment: name });
const refs = {};
for (const file of [...sources, ...files]) {
if (refs[file]) throw new Error(`plugins/ios-target-files: ${file} listed twice for ${name}`);
const uuid = project.generateUuid();
const type = FILE_TYPES[file.split('.').pop()];
objects.PBXFileReference[uuid] = {
isa: 'PBXFileReference',
...(type ? { lastKnownFileType: type } : {}),
name: quote(file),
path: quote(file),
sourceTree: '"<group>"',
};
objects.PBXFileReference[`${uuid}_comment`] = file;
group.children.push({ value: uuid, comment: file });
refs[file] = uuid;
}
const { uuid: phaseUuid, buildPhase } = project.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', targetUuid);
for (const file of sources) {
const uuid = project.generateUuid();
const comment = `${file} in Sources`;
objects.PBXBuildFile[uuid] = { isa: 'PBXBuildFile', fileRef: refs[file], fileRef_comment: file };
objects.PBXBuildFile[`${uuid}_comment`] = comment;
buildPhase.files.push({ value: uuid, comment });
}
return { groupUuid, phaseUuid };
}
module.exports = { addTargetFiles, hasTarget };
+10 -10
View File
@@ -17,6 +17,7 @@ const fs = require('node:fs');
const path = require('node:path');
const { withDangerousMod, withXcodeProject, createRunOncePlugin } = require('@expo/config-plugins');
const { withAppSources } = require('../ios-app-sources');
const { addTargetFiles, hasTarget } = require('../ios-target-files');
const TARGET = 'BulwarkWidget';
const DEPLOYMENT_TARGET = '15.1';
@@ -101,23 +102,22 @@ function withWidgetFiles(config) {
function addWidgetTarget(cfg, { teamId, widgetProfile }) {
const project = cfg.modResults;
if (project.pbxTargetByName(TARGET)) return;
if (hasTarget(project, TARGET)) return;
const objects = project.hash.project.objects;
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
const group = project.addPbxGroup(
[...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`],
TARGET,
TARGET,
);
const mainGroupId = project.getFirstProject().firstProject.mainGroup;
project.addToPbxGroup(group.uuid, mainGroupId);
const bundleId = `${cfg.ios.bundleIdentifier}.Widget`;
const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId);
project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
// Its own group and file references: SharedKeychain.swift is also in the
// app and the notification extension (plugins/ios-target-files.js).
addTargetFiles(project, {
name: TARGET,
targetUuid: target.uuid,
sources: SOURCE_FILES,
files: [`${TARGET}-Info.plist`, `${TARGET}.entitlements`],
});
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
+132
View File
@@ -0,0 +1,132 @@
// Checks the structure of a generated ios/<project>.xcodeproj that CocoaPods'
// xcodeproj gem refuses to save (pod install fails in React Native's
// post-install hook with "Consistency issue: no parent for object ..."):
//
// - every build file in a target's Sources phase points at an existing file
// reference, and appears in no other build phase;
// - every such file reference sits in exactly one group, reachable from the
// project's main group.
//
// It also lists, per target, which file each Sources entry compiles (the path
// relative to ios/), so a test can check that a target builds its own copy.
//
// node scripts/check-xcode-project.mjs ios/BulwarkMobile.xcodeproj/project.pbxproj
//
// The tests (src/lib/__tests__/ios-xcode-project.test.ts) run the iOS config
// plugins over Expo's template and use this on the result.
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const require = createRequire(import.meta.url);
const isComment = (key) => key.endsWith('_comment');
const entries = (section) => Object.entries(section ?? {}).filter(([key]) => !isComment(key));
const unquote = (value) => (typeof value === 'string' ? value.replace(/^"(.*)"$/, '$1') : value);
/** Parses a project.pbxproj with the `xcode` package the config plugins use. */
export function parseProject(pbxprojPath) {
const configPlugins = path.dirname(require.resolve('@expo/config-plugins/package.json'));
const xcode = require(require.resolve('xcode', { paths: [configPlugins] }));
const project = xcode.project(pbxprojPath);
project.parseSync();
return project;
}
/**
* @param project a parsed `xcode` project (or a project.pbxproj path)
* @returns {{ problems: string[], sources: Record<string, string[]> }}
* problems: one line per broken entry (empty when the project is sound);
* sources: target name -> paths (relative to ios/) its Sources phase compiles.
*/
export function checkXcodeProject(project) {
if (typeof project === 'string') project = parseProject(project);
const objects = project.hash.project.objects;
const fileRefs = objects.PBXFileReference ?? {};
const buildFiles = objects.PBXBuildFile ?? {};
const groups = objects.PBXGroup ?? {};
const problems = [];
// Parents of every group and file reference, walking down from the main group.
const parents = new Map();
const groupPath = new Map();
const mainGroup = project.getFirstProject().firstProject.mainGroup;
const walk = (groupId, base, seen) => {
if (seen.has(groupId)) return;
seen.add(groupId);
const group = groups[groupId];
if (!group) return;
const dir = group.path ? path.posix.join(base, unquote(group.path)) : base;
groupPath.set(groupId, dir);
for (const child of group.children ?? []) {
const list = parents.get(child.value) ?? [];
list.push(groupId);
parents.set(child.value, list);
if (groups[child.value]) walk(child.value, dir, seen);
}
};
walk(mainGroup, '', new Set());
// Which build phases list each build file.
const phasesOf = new Map();
for (const [isa, section] of Object.entries(objects)) {
if (!isa.endsWith('BuildPhase')) continue;
for (const [phaseId, phase] of entries(section)) {
for (const file of phase.files ?? []) {
const list = phasesOf.get(file.value) ?? [];
list.push(`${isa} ${phaseId}`);
phasesOf.set(file.value, list);
}
}
}
const sources = {};
for (const [, target] of entries(objects.PBXNativeTarget)) {
const name = unquote(target.name);
sources[name] = [];
for (const phaseRef of target.buildPhases ?? []) {
const phase = objects.PBXSourcesBuildPhase?.[phaseRef.value];
if (!phase) continue;
for (const file of phase.files ?? []) {
const label = `${name}: ${file.comment ?? file.value}`;
const buildFile = buildFiles[file.value];
if (!buildFile) {
problems.push(`${label}: build file ${file.value} does not exist`);
continue;
}
const phases = phasesOf.get(file.value) ?? [];
if (phases.length !== 1) {
problems.push(`${label}: build file ${file.value} is in ${phases.length} build phases (${phases.join(', ')})`);
}
const ref = fileRefs[buildFile.fileRef];
if (!ref) {
problems.push(`${label}: file reference ${buildFile.fileRef} does not exist`);
continue;
}
const owners = parents.get(buildFile.fileRef) ?? [];
if (owners.length !== 1) {
problems.push(`${label}: file reference ${buildFile.fileRef} is in ${owners.length} groups reachable from the main group`);
continue;
}
const refPath = unquote(ref.path ?? ref.name);
const tree = unquote(ref.sourceTree);
sources[name].push(tree === '<group>' ? path.posix.join(groupPath.get(owners[0]), refPath) : `${tree}/${refPath}`);
}
}
}
return { problems, sources };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const file = process.argv[2];
if (!file) {
console.error('usage: node scripts/check-xcode-project.mjs <path to project.pbxproj>');
process.exit(2);
}
const { problems, sources } = checkXcodeProject(file);
for (const [target, files] of Object.entries(sources)) console.log(`${target}:\n ${files.join('\n ')}`);
if (problems.length) {
console.error(`\n${problems.length} problem(s):\n ${problems.join('\n ')}`);
process.exit(1);
}
}
+137
View File
@@ -0,0 +1,137 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
// The iOS config plugins (plugins/ios-push, ios-client-cert, ios-widget) run
// over Expo's SDK 54 template the way `expo prebuild --platform ios` does,
// and the Xcode project they leave must be one CocoaPods can save: pod
// install failed with "Consistency issue: no parent for object
// SharedKeychain.swift" when the notification extension and the widget
// shared one file reference and one build file for their copies of it.
const ROOT = path.resolve(__dirname, '../../..');
const req = createRequire(path.join(ROOT, 'package.json'));
const { compileModsAsync, withPlugins } = req('@expo/config-plugins');
type Check = { problems: string[]; sources: Record<string, string[]> };
type Checker = { checkXcodeProject: (pbxproj: string) => Check };
const PROJECT = 'BulwarkMobile';
/** Unpacks the files under package/ios/ of Expo's template tarball into dir/ios. */
function unpackTemplateIos(dir: string) {
const tgz = path.join(path.dirname(req.resolve('expo/package.json')), 'template.tgz');
const tar = zlib.gunzipSync(fs.readFileSync(tgz));
const text = (start: number, length: number) => tar.toString('utf8', start, start + length).replace(/\0.*$/s, '');
for (let offset = 0; offset + 512 <= tar.length; ) {
const name = text(offset, 100);
if (!name) break;
const size = parseInt(text(offset + 124, 12).trim() || '0', 8);
const type = text(offset + 156, 1);
const full = [text(offset + 345, 155), name].filter(Boolean).join('/');
const body = offset + 512;
if ((type === '0' || type === '') && full.startsWith('package/ios/')) {
const out = path.join(dir, full.slice('package/'.length));
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, tar.subarray(body, body + size));
}
offset = body + Math.ceil(size / 512) * 512;
}
}
/** Renames the template's HelloWorld project the way expo prebuild does. */
function renameTemplate(dir: string) {
const walk = (p: string) => {
for (const entry of fs.readdirSync(p, { withFileTypes: true })) {
let full = path.join(p, entry.name);
if (entry.name.includes('HelloWorld')) {
const renamed = path.join(p, entry.name.replace(/HelloWorld/g, PROJECT));
fs.renameSync(full, renamed);
full = renamed;
}
if (entry.isDirectory()) walk(full);
else if (!full.endsWith('.png')) {
const body = fs.readFileSync(full, 'utf8');
fs.writeFileSync(full, body.replace(/HelloWorld/g, PROJECT).replace(/helloworld/g, PROJECT.toLowerCase()));
}
}
};
walk(path.join(dir, 'ios'));
}
/** The app's config with only the plugins that change the Xcode project. */
function appConfig(projectRoot: string) {
const config = {
_internal: { projectRoot },
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
version: '1.0.0',
ios: { bundleIdentifier: 'org.bulwarkmail.mobile', buildNumber: '1' },
};
return withPlugins(config, [
[req('./plugins/ios-push'), { apsEnvironment: 'production', teamId: 'TEAMID' }],
req('./plugins/ios-client-cert'),
[req('./plugins/ios-widget'), { teamId: 'TEAMID' }],
]);
}
const prebuild = (projectRoot: string) =>
compileModsAsync(appConfig(projectRoot), { projectRoot, platforms: ['ios'], assertMissingModProviders: false });
describe('the Xcode project after the iOS config plugins', () => {
let dir = '';
let pbxproj = '';
let checker: Checker;
let first: Check;
const env = { ...process.env };
beforeAll(async () => {
// The release workflow's signing variables would leave the widget out.
for (const key of ['APS_ENVIRONMENT', 'APPLE_TEAM_ID', 'IOS_APP_PROFILE', 'IOS_NSE_PROFILE', 'IOS_WIDGET_PROFILE']) {
delete process.env[key];
}
checker = (await import(pathToFileURL(path.join(ROOT, 'scripts/check-xcode-project.mjs')).href)) as Checker;
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bulwark-ios-prebuild-'));
unpackTemplateIos(dir);
renameTemplate(dir);
pbxproj = path.join(dir, 'ios', `${PROJECT}.xcodeproj`, 'project.pbxproj');
await prebuild(dir);
first = checker.checkXcodeProject(pbxproj);
}, 120_000);
afterAll(() => {
process.env = env;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
it('gives every compiled file a build file of its own in a group of its own', () => {
expect(first.problems).toEqual([]);
});
it('compiles each target\'s own copy of the shared sources', () => {
const { sources } = first;
expect(sources[PROJECT]).toEqual(
expect.arrayContaining(['SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift', 'PushRenewalTask.swift'].map((f) => `${PROJECT}/${f}`)),
);
expect([...sources.NotificationService].sort()).toEqual(
['Jmap.swift', 'NotificationService.swift', 'PushRenewal.swift', 'SharedKeychain.swift'].map((f) => `NotificationService/${f}`),
);
expect([...sources.BulwarkWidget].sort()).toEqual(['BulwarkWidget/BulwarkWidget.swift', 'BulwarkWidget/SharedKeychain.swift']);
// Every copy sits where the plugins put the file.
for (const files of Object.values(sources)) {
for (const file of files) expect(fs.existsSync(path.join(dir, 'ios', file)), file).toBe(true);
}
});
it('leaves the project as it was when prebuild runs again without --clean', async () => {
const before = fs.readFileSync(pbxproj, 'utf8');
await prebuild(dir);
const again = checker.checkXcodeProject(pbxproj);
expect(again.problems).toEqual([]);
expect(again.sources).toEqual(first.sources);
expect(fs.readFileSync(pbxproj, 'utf8')).toBe(before);
}, 120_000);
});