mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
+10
-11
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user