diff --git a/plugins/ios-push/index.js b/plugins/ios-push/index.js index 4dcfb573..0c7f2247 100644 --- a/plugins/ios-push/index.js +++ b/plugins/ios-push/index.js @@ -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); diff --git a/plugins/ios-target-files.js b/plugins/ios-target-files.js new file mode 100644 index 00000000..e11aa5a5 --- /dev/null +++ b/plugins/ios-target-files.js @@ -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/) 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: '""' }; + 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: '""', + }; + 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 }; diff --git a/plugins/ios-widget/index.js b/plugins/ios-widget/index.js index 9cd5420a..9f766908 100644 --- a/plugins/ios-widget/index.js +++ b/plugins/ios-widget/index.js @@ -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); diff --git a/scripts/check-xcode-project.mjs b/scripts/check-xcode-project.mjs new file mode 100644 index 00000000..a4b0fd90 --- /dev/null +++ b/scripts/check-xcode-project.mjs @@ -0,0 +1,132 @@ +// Checks the structure of a generated ios/.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 }} + * 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 === '' ? 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 '); + 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); + } +} diff --git a/src/lib/__tests__/ios-xcode-project.test.ts b/src/lib/__tests__/ios-xcode-project.test.ts new file mode 100644 index 00000000..b3ffff1b --- /dev/null +++ b/src/lib/__tests__/ios-xcode-project.test.ts @@ -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 }; +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); +});