mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
133 lines
5.4 KiB
JavaScript
133 lines
5.4 KiB
JavaScript
// 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);
|
|
}
|
|
}
|