react-native
Version:
A framework for building native apps using React
3,300 lines • 123 kB
JavaScript
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
'use strict';
/**
* generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager
* integration toolkit for an existing `<App>.xcodeproj`.
*
* `injectSpmIntoExistingXcodeproj` adds the SPM package references, a generated
* `.xcconfig` carrying the React build settings, the "Sync SPM Autolinking" build
* phase, and a scheme pre-action to a user's existing project — purely
* additively, recording every edit in a `.spm-injected.json` marker.
* `removeSpmInjection` is the exact inverse (used by `spm deinit`). Consumed as a
* library by setup-apple-spm.js; not a CLI.
*/
const {
BUILTIN_FRAMEWORKS,
FLAVORED_FRAMEWORKS_MANIFEST,
readFlavoredFrameworksManifest,
readPluginFrameworks,
} = require('./flavored-frameworks');
const {parseConfigCommandJson} = require('./generate-spm-autolinking-config');
const {
addArrayMembers,
commentSafe,
ensureScalarField,
findApplicationTargets,
findField,
findObjectByUuid,
findProjectObject,
insertObjectsIntoSection,
namespacedUUID,
quoteIfNeeded,
removeArrayMembersByUuid,
removeArrayStringValues,
removeDanglingJavaScriptCoreRef,
removeEmptyPodsGroup,
removeField,
removeObjectByUuid,
serializeEntry,
setScalarField,
uuidComment,
} = require('./spm-pbxproj');
const {
AUTOLINKED_PACKAGE_NAME,
REACT_CODEGEN_APP_PRODUCTS,
REACT_CODEGEN_PACKAGE_NAME,
REACT_CODEGEN_PRODUCTS,
REACT_NATIVE_PACKAGE_NAME,
REACT_NATIVE_PRODUCTS,
isValidScriptPhaseId,
isValidScriptPhaseName,
makeLogger,
remotePackageConfig,
} = require('./spm-utils');
const {
readXcconfig,
xcconfigIncludes,
xcconfigReferencePaths,
} = require('./xcconfig-files');
const fs = require('node:fs');
const path = require('node:path');
/*:: import type {
FlavoredFrameworkManifestEntry,
PluginScriptPhase,
XcframeworkSlice,
} from './spm-types'; */
const {log} = makeLogger('generate-spm-xcodeproj');
// Sidecar inside a USER-OWNED xcodeproj that SPM packages were injected into in
// place. Records the host project's root UUID + every edit so `spm deinit`
// (removeSpmInjection) can surgically revert and re-runs stay idempotent.
const SPM_INJECTED_MARKER = '.spm-injected.json';
// Manifest of plugin-contributed sources that must COMPILE INTO THE APP TARGET
// (e.g. Expo's ExpoModulesProvider.swift — an `@objc` class only reaches the
// ObjC classlist, and so is discoverable via NSClassFromString, when it
// compiles into the app target, NOT the static Autolinked aggregate). Written
// by generate-spm-autolinking.js (the plugin merge) BEFORE setupXcodeproj runs
// on both `add` and `update`, so the injector reads it synchronously. Path is
// relative to the app root (== SRCROOT / the .xcodeproj's dir).
const SPM_GENERATED_SOURCES_MANIFEST = path.join(
'build',
'generated',
'autolinking',
'.spm-plugin-generated-sources.json',
);
// Manifest of plugin-contributed build-time shell phases for the app target —
// SwiftPM has no `script_phase`, so a framework that must generate content into
// the app bundle (expo-constants' `EXConstants.bundle/app.config`) declares one
// through the plugin contract.
const SPM_SCRIPT_PHASES_MANIFEST = path.join(
'build',
'generated',
'autolinking',
'.spm-plugin-script-phases.json',
);
// Everything that follows the installed React Native version and frameworks is
// written to these files on every `spm add`/`update`, so that a routine update
// leaves project.pbxproj alone — it only references them. Relative to the app
// root (== SRCROOT). Deliberately NOT under `build/`: that tree holds the SPM
// package roots (build/xcframeworks, build/generated/autolinking,
// build/generated/ios) and is gitignored + regenerated by the in-build "Sync
// SPM Autolinking" phase. These three files are read while Xcode resolves
// build settings and build-phase scripts — before any build phase has run —
// so unlike the package roots they cannot be lazily regenerated at build
// time. They must exist on a fresh clone, so they are committed like
// project.pbxproj itself.
const GENERATED_XCCONFIG_PATH = 'ReactNativeSPM/ReactNativeSPM.xcconfig';
const SYNC_AUTOLINKING_SCRIPT_PATH =
'ReactNativeSPM/Scripts/sync-autolinking.sh';
const EMBED_FRAMEWORKS_SCRIPT_PATH =
'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh';
// The single navigator group all injected generated sources are parented under
// (created on first use). Its namespacedUUID id + display name.
const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources';
const SPM_GENERATED_SOURCES_GROUP_NAME = 'SPM Generated Sources';
// pbxproj `lastKnownFileType` per source extension. v1 plugins emit Swift only;
// .m/.mm are mapped as future-proofing (the plugin contract permits ObjC/ObjC++
// sources). An unmapped extension is skipped with a loud log.
const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = {
'.swift': 'sourcecode.swift',
'.m': 'sourcecode.c.objc',
'.mm': 'sourcecode.cpp.objcpp',
};
// Maps each SPM product to its sub-package path (relative to app root).
// The xcodeproj must reference each sub-package directly so Xcode can
// resolve the product dependencies — SPM doesn't expose transitive products.
const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ =
[
...REACT_NATIVE_PRODUCTS.map(product => ({
product,
packagePath: 'build/xcframeworks',
packageName: REACT_NATIVE_PACKAGE_NAME,
})),
{
product: AUTOLINKED_PACKAGE_NAME,
packagePath: 'build/generated/autolinking',
packageName: AUTOLINKED_PACKAGE_NAME,
},
...REACT_CODEGEN_APP_PRODUCTS.map(product => ({
product,
packagePath: 'build/generated/ios',
packageName: REACT_CODEGEN_PACKAGE_NAME,
})),
];
/*::
type RemoteCfg = {url: string, version: string, identity: string};
// Precise record of the build-setting edits injection made to ONE build config,
// written by versions that put the React build settings into project.pbxproj
// directly. Now only read back, so the next sync or deinit can reverse exactly
// those edits (and nothing the user already had).
type BuildSettingChange = {
configUuid: string,
createdArrayKeys: Array<string>,
appendedArrayValues: {[string]: Array<string>},
createdScalars: Array<string>,
// Scalars whose pre-injection value was replaced (key → original raw
// value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once
// CocoaPods is deintegrated. Deinit restores the original.
replacedScalars?: {[string]: string},
// Array settings that existed as a SCALAR and were promoted to a `( … )`
// array (key → the pre-injection raw value text, quotes included). Deinit
// restores that value verbatim; removing the injected members would leave
// the promoted array and its `"$(inherited)"` seed behind.
promotedArrayScalars?: {[string]: string},
};
// An array field injection CREATED (rather than appended to a pre-existing
// one), so deinit removes the whole field and lands byte-identical.
type CreatedArrayField = {container: 'project' | 'target', key: string};
// How the app configurations were based on the generated xcconfig. Deinit
// clears `baseConfigurationReference` only on these, and only while it still
// points at `fileRefUuid`. `includedByConfigUuids` keep a base of the user's
// own whose xcconfig #includes the generated one, so deinit keeps that file;
// markers written before it existed lack it. `removedPodsRootReactNativePath`
// is sticky and informational: deinit does not put that value back, because it
// resolves empty without CocoaPods.
type XcconfigRecord = {
fileRefUuid: string,
configUuids: Array<string>,
includedByConfigUuids?: Array<string>,
removedPodsRootReactNativePath: boolean,
};
// A plugin-contributed source, normalized for pbxproj emission. `path` is
// SRCROOT-relative when under the app root, else absolute; `sourceTree` is the
// matching pbxproj token ('SOURCE_ROOT' or '"<absolute>"').
type GeneratedSource = {path: string, name: string, sourceTree: string, fileType: string};
type SpmGraph = {
uniquePackages: Array<{packagePath: string, packageName: string}>,
localPkgRefs: Array<{uuid: string, packagePath: string, comment: string}>,
remotePkgRef: ?{uuid: string, url: string, version: string, identity: string, comment: string},
products: Array<{product: string, depUuid: string, buildFileUuid: string, pkgRefUuid: string, refComment: string}>,
};
*/
/**
* Resolve the SPM dependency graph (package references + product
* dependencies + their frameworks build files) from SPM_PRODUCT_PACKAGES.
* `mkUuid(section, id)` supplies UUIDs, seeded with the host project's root
* UUID so injected IDs are stable across re-runs and collision-safe.
*/
function buildSpmDependencyGraph(
mkUuid /*: (section: string, id: string) => string */,
remote /*: ?RemoteCfg */,
) /*: SpmGraph */ {
// Remote mode: ReactNative-family products move to the remote package.
const productPackages = SPM_PRODUCT_PACKAGES.map(e =>
remote != null && e.packagePath === 'build/xcframeworks'
? {...e, packagePath: 'REMOTE', packageName: remote.identity}
: e,
);
const uniquePackages = Array.from(
new Map(
productPackages
.filter(e => e.packagePath !== 'REMOTE')
.map(e => [
e.packagePath,
{packagePath: e.packagePath, packageName: e.packageName},
]),
).values(),
);
const localPkgRefs = uniquePackages.map(pkg => ({
uuid: mkUuid('XCLocalSwiftPackageReference', pkg.packagePath),
packagePath: pkg.packagePath,
comment: `XCLocalSwiftPackageReference "${pkg.packagePath}"`,
}));
const remotePkgRef =
remote != null
? {
uuid: mkUuid('XCRemoteSwiftPackageReference', remote.url),
url: remote.url,
version: remote.version,
identity: remote.identity,
comment: `XCRemoteSwiftPackageReference "${remote.identity}"`,
}
: null;
const localByPath = new Map(localPkgRefs.map(r => [r.packagePath, r]));
const products = productPackages.map(entry => {
const {product, packagePath} = entry;
const isRemote = packagePath === 'REMOTE' && remotePkgRef != null;
const pkgRefUuid = isRemote
? // $FlowFixMe[incompatible-use] guarded by isRemote
remotePkgRef.uuid
: // $FlowFixMe[incompatible-use] every non-REMOTE path is in localByPath
localByPath.get(packagePath).uuid;
const refComment = isRemote
? // $FlowFixMe[incompatible-use] guarded by isRemote
`XCRemoteSwiftPackageReference "${remotePkgRef.identity}"`
: `XCLocalSwiftPackageReference "${packagePath}"`;
return {
product,
depUuid: mkUuid('XCSwiftPackageProductDependency', product),
buildFileUuid: mkUuid('PBXBuildFile', `spm:${product}`),
pkgRefUuid,
refComment,
};
});
return {uniquePackages, localPkgRefs, remotePkgRef, products};
}
/**
* Render the SPM graph into pbxproj section entry objects the in-place injector
* splices into an existing project.
*/
/*:: type PbxEntryT = {uuid: string, comment: string, fields: {[string]: string}}; */
function spmGraphToEntries(
graph /*: SpmGraph */,
) /*: {localRefs: Array<PbxEntryT>, remoteRef: ?PbxEntryT, productDeps: Array<PbxEntryT>, buildFiles: Array<PbxEntryT>} */ {
const localRefs /*: Array<PbxEntryT> */ = graph.localPkgRefs.map(ref => ({
uuid: ref.uuid,
comment: ref.comment,
fields: {
isa: 'XCLocalSwiftPackageReference',
relativePath: quoteIfNeeded(ref.packagePath),
},
}));
const remote = graph.remotePkgRef;
const remoteRef /*: ?PbxEntryT */ =
remote != null
? {
uuid: remote.uuid,
comment: remote.comment,
fields: {
isa: 'XCRemoteSwiftPackageReference',
repositoryURL: quoteIfNeeded(remote.url),
requirement: `{\n\t\t\t\tkind = exactVersion;\n\t\t\t\tversion = "${remote.version}";\n\t\t\t}`,
},
}
: null;
const productDeps /*: Array<PbxEntryT> */ = graph.products.map(p => ({
uuid: p.depUuid,
comment: p.product,
fields: {
isa: 'XCSwiftPackageProductDependency',
package: `${p.pkgRefUuid} /* ${p.refComment} */`,
productName: quoteIfNeeded(p.product),
},
}));
const buildFiles /*: Array<PbxEntryT> */ = graph.products.map(p => ({
uuid: p.buildFileUuid,
comment: `${p.product} in Frameworks`,
fields: {
isa: 'PBXBuildFile',
productRef: `${p.depUuid} /* ${p.product} */`,
},
}));
return {localRefs, remoteRef, productDeps, buildFiles};
}
/**
* Build a PBXShellScriptBuildPhase entry. `inputPaths`/`outputPaths` accept
* either a plain path array or an already-serialized pbxproj list.
* `alwaysOutOfDate` emits Xcode's own `alwaysOutOfDate = 1;` (unquoted, right
* after `isa`, so a project Xcode rewrites stays diff-free) and is omitted
* entirely when false. `comment` overrides the cosmetic `/* … */` label, which
* otherwise derives from `name`.
*/
function shellScriptPhase(
phaseUUID /*: string */,
name /*: string */,
script /*: string */,
options /*: {inputPaths?: ?(string | ReadonlyArray<string>), outputPaths?: ?(string | ReadonlyArray<string>), alwaysOutOfDate?: ?boolean, comment?: string} */ = {},
) /*: {uuid: string, comment: string, fields: {[string]: string}} */ {
const empty = '(\n\t\t\t)';
const pathList = (
value /*: ?(string | ReadonlyArray<string>) */,
) /*: string */ => {
if (value == null) {
return empty;
}
return typeof value === 'string' ? value : pbxPathList(value);
};
return {
uuid: phaseUUID,
comment: options.comment ?? name,
fields: {
isa: 'PBXShellScriptBuildPhase',
...(options.alwaysOutOfDate === true ? {alwaysOutOfDate: '1'} : {}),
buildActionMask: '2147483647',
files: empty,
inputFileListPaths: empty,
inputPaths: pathList(options.inputPaths),
name: quoteIfNeeded(name),
outputFileListPaths: empty,
outputPaths: pathList(options.outputPaths),
runOnlyForDeploymentPostprocessing: '0',
// React Native's own bodies here start with `set -euo pipefail`, which
// /bin/sh rejects on a host where it isn't bash (e.g. dash's `set -o
// pipefail: Illegal option`). Run every phase under bash, including
// plugin-contributed ones, so a plugin's body can rely on it too.
shellPath: '/bin/bash',
shellScript: quoteIfNeeded(script),
},
};
}
function frameworkSettingPrefix(id /*: string */) /*: string */ {
return `RN_SPM_${id.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`;
}
function flavorForBuildConfiguration(
configurationName /*: string */,
) /*: 'debug' | 'release' */ {
const lower = configurationName.toLowerCase();
return lower.includes('debug') || lower.includes('development')
? 'debug'
: 'release';
}
function buildConfigurationName(
text /*: string */,
configUuid /*: string */,
) /*: string */ {
const config = findObjectByUuid(text, configUuid);
const name = config != null ? findField(text, config, 'name') : null;
if (name == null) {
throw new Error(`pbxproj: build configuration ${configUuid} has no name`);
}
return name.value.replace(/^"|"$/g, '');
}
/** The per-slice framework locations, as xcconfig keys and values. */
function frameworkConditionalSettings(
frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
) /*: Array<{key: string, value: string}> */ {
const settings /*: Array<{key: string, value: string}> */ = [];
for (const framework of frameworks) {
const prefix = frameworkSettingPrefix(framework.id);
const bySdk /*: Map<string, Array<XcframeworkSlice>> */ = new Map();
// The injected target is an Apple mobile/Catalyst application. Native
// macOS slices share `sdk=macosx*` with Catalyst and cannot be
// distinguished by an XCBuildConfiguration condition, so use the Catalyst
// slice and leave native-mac packaging out of this iOS integration.
for (const slice of framework.slices.filter(
candidate => candidate.platform !== 'macos',
)) {
const existing = bySdk.get(slice.sdk) ?? [];
existing.push(slice);
bySdk.set(slice.sdk, existing);
}
for (const [sdk, slices] of bySdk) {
const emit = (slice /*: XcframeworkSlice */, condition /*: string */) => {
const root =
`$(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/` +
`${framework.artifactRelativePath}/${slice.libraryIdentifier}`;
settings.push(
{
key: `${prefix}_FRAMEWORK${condition}`,
value: `${root}/${slice.libraryPath}`,
},
{
key: `${prefix}_BINARY${condition}`,
value: `${root}/${slice.binaryPath}`,
},
{key: `${prefix}_SEARCH_PATH${condition}`, value: root},
);
};
if (slices.length === 1) {
emit(slices[0], `[sdk=${sdk}]`);
continue;
}
const seenArchitectures /*: Set<string> */ = new Set();
for (const slice of slices) {
for (const architecture of slice.architectures) {
if (seenArchitectures.has(architecture)) {
throw new Error(
`${framework.frameworkName} has ambiguous ${sdk}/${architecture} slices`,
);
}
seenArchitectures.add(architecture);
emit(slice, `[sdk=${sdk}][arch=${architecture}]`);
}
}
}
}
return settings;
}
// List-valued settings, in xcconfig syntax: quotes group a value whose expansion
// may contain spaces into one list element.
function frameworkListSettings(
frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
) /*: Array<{key: string, values: Array<string>}> */ {
return [
{
key: 'OTHER_LDFLAGS',
values: [
'-ObjC',
...frameworks.map(
framework => `"$(${frameworkSettingPrefix(framework.id)}_BINARY)"`,
),
],
},
{
key: 'FRAMEWORK_SEARCH_PATHS',
values: frameworks.map(
framework => `"$(${frameworkSettingPrefix(framework.id)}_SEARCH_PATH)"`,
),
},
{
key: 'LD_RUNPATH_SEARCH_PATHS',
values: ['@executable_path/Frameworks'],
},
];
}
function generatedScript(body /*: string */) /*: string */ {
return `#!/bin/bash
# Generated by React Native on every \`spm add\` and \`spm update\`.
# Do not edit: changes are overwritten.
${body}`;
}
// What a build phase runs in place of its generated script, which lives in a
// file so that the phase itself never changes. The file is committed (see
// GENERATED_XCCONFIG_PATH's comment) so a fresh clone always has it; the
// guard below is for the rarer case of a checkout that predates this
// mechanism, or the file having been deleted by hand.
function generatedScriptWrapper(scriptPath /*: string */) /*: string */ {
return `SCRIPT="$SRCROOT/${scriptPath}"
if [ ! -f "$SCRIPT" ]; then
echo "error: $SCRIPT is missing. Run 'npx react-native spm update' to generate it."
exit 1
fi
exec /bin/bash "$SCRIPT"`;
}
function pbxPathList(paths /*: ReadonlyArray<string> */) /*: string */ {
if (paths.length === 0) {
return '(\n\t\t\t)';
}
return `(\n${paths
.map(value => `\t\t\t\t${quoteIfNeeded(value)},\n`)
.join('')}\t\t\t)`;
}
function buildEmbedFrameworksScript(
frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
) /*: string */ {
const validations = frameworks
.map(framework => {
const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`;
return `validate_framework "\${${variable}:-}" "${framework.frameworkName}.framework"`;
})
.join('\n');
const copies = frameworks
.map(framework => {
const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`;
return `copy_and_sign "\${${variable}:-}" "${framework.frameworkName}.framework"`;
})
.join('\n');
return `set -euo pipefail
destination="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH"
mkdir -p "$destination"
validate_framework() {
source="$1"
name="$2"
if [ -z "$source" ] || [ ! -d "$source" ]; then
echo "error: React Native SwiftPM framework '$name' is unavailable for configuration '$CONFIGURATION' and SDK '$SDK_NAME': $source"
exit 1
fi
binary="\${name%.framework}"
if [ ! -e "$source/$binary" ] && [ ! -e "$source/Versions/Current/$binary" ]; then
echo "error: React Native SwiftPM framework '$name' is invalid for configuration '$CONFIGURATION': expected $source/$binary or $source/Versions/Current/$binary"
exit 1
fi
}
copy_and_sign() {
source="$1"
name="$2"
/usr/bin/rsync -a --delete "$source/" "$destination/$name/"
if [ "\${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]; then
identity="\${EXPANDED_CODE_SIGN_IDENTITY:--}"
if [ "$identity" = "-" ]; then
/usr/bin/codesign --force --sign - --timestamp=none --preserve-metadata=identifier,entitlements,flags "$destination/$name"
else
/usr/bin/codesign --force --sign "$identity" --preserve-metadata=identifier,entitlements,flags "$destination/$name"
fi
fi
}
${validations}
${copies}
`;
}
/**
* The app target's `buildPhases` members, in the order the file lists them.
* Line-leading UUIDs only: a member's trailing comment carries a plugin-supplied
* phase name, and one that happens to look like a UUID would otherwise read as an
* extra member — leaving the actual order permanently at odds with the declared
* one, and offering a non-member as a re-seating anchor.
*/
function buildPhaseOrder(
text /*: string */,
target /*: {bodyOpen: number, bodyClose: number, ...} */,
) /*: Array<string> */ {
const field = findField(text, target, 'buildPhases');
if (field == null) {
return [];
}
return [...field.value.matchAll(/^[\t ]*([0-9A-Fa-f]{24})\b/gm)].map(
m => m[1],
);
}
/**
* Rewrite the trailing comment Xcode keeps beside a UUID — on the object's own
* definition line and on every array-member line referencing it. Xcode
* normalizes those comments on its next write, so leaving a stale one behind
* (after a plugin renames a phase) plants a spurious diff in the user's repo.
* No-op when the comment already reads `comment`.
*/
function setUuidComment(
text /*: string */,
uuid /*: string */,
comment /*: string */,
) /*: string */ {
return text.replace(
new RegExp(`(\\n[\\t ]*${uuid})(?: /\\* [^\\n]*? \\*/)?( = \\{|,)`, 'g'),
(_match, head, tail) => `${head}${uuidComment(comment)}${tail}`,
);
}
/*:: type SeatedPhase = {uuid: string, comment: string, position: 'beforeCompile' | 'end'}; */
/**
* Seat the plugin phases in the order the manifest declares: `beforeCompile`
* ones directly after the Sync SPM Autolinking phase (which stays first — it
* regenerates the content everything else reads) and always before Sources,
* `end` ones at the true end of `buildPhases`, after the app's own JS-bundle
* phase. The other members keep their relative order.
*
* `beforeCompile` is anchored on Sources, not merely on the sync phase: RN never
* re-seats its own sync phase, so a user who drags it below Sources would
* otherwise have every `beforeCompile` phase seated after compilation — the one
* thing the position promises not to do. Without a Sources phase the sync phase
* is the anchor.
*
* Rewrites the membership lines ONLY when the actual order differs from that,
* which is what keeps an unchanged sync byte-identical — `addBuildPhaseAfter`
* and `addArrayMembers` both short-circuit on a UUID that is already a member,
* so re-placing has to be driven from here. A phase a user dragged elsewhere in
* Xcode is therefore moved back: the declared position wins.
*/
function seatScriptPhases(
input /*: string */,
targetUuid /*: string */,
syncPhaseUuid /*: string */,
phases /*: ReadonlyArray<SeatedPhase> */,
sourcesPhaseUuid /*: ?string */,
) /*: string */ {
const pluginUuids = new Set(phases.map(p => p.uuid));
const actual = buildPhaseOrder(
input,
findApplicationTargetByUuid(input, targetUuid),
);
const others = actual.filter(uuid => !pluginUuids.has(uuid));
const beforeCompile = phases.filter(p => p.position === 'beforeCompile');
const atEnd = phases.filter(p => p.position !== 'beforeCompile');
const sourcesAt =
sourcesPhaseUuid != null ? others.indexOf(sourcesPhaseUuid) : -1;
const afterSyncAt = others.indexOf(syncPhaseUuid) + 1;
// 0 — no sync phase member, or Sources ahead of it — makes the beforeCompile
// phases lead the array, matching addArrayMembers' prepend fallback below.
const insertAt =
sourcesAt >= 0 ? Math.min(afterSyncAt, sourcesAt) : afterSyncAt;
const desired = [
...others.slice(0, insertAt),
...beforeCompile.map(p => p.uuid),
...others.slice(insertAt),
...atEnd.map(p => p.uuid),
];
if (
desired.length === actual.length &&
desired.every((uuid, i) => uuid === actual[i])
) {
return input;
}
// File-wide removal is safe for a shell-phase UUID: it appears on its own
// definition line (which ends `= {`, never matched) and on `buildPhases`
// member lines. The uncovered case is a user who copied the same phase object
// into a SECOND target's buildPhases — it is stripped there too.
let text = removeArrayMembersByUuid(input, [...pluginUuids]);
const target = () => findApplicationTargetByUuid(text, targetUuid);
let anchor = insertAt > 0 ? others[insertAt - 1] : null;
for (const member of beforeCompile) {
text =
anchor == null
? addArrayMembers(text, target(), 'buildPhases', [member], {
prepend: true,
})
: addBuildPhaseAfter(text, target(), anchor, member);
anchor = member.uuid;
}
for (const member of atEnd) {
text = addArrayMembers(text, target(), 'buildPhases', [member]);
}
return text;
}
function addBuildPhaseAfter(
text /*: string */,
target /*: {bodyOpen: number, bodyClose: number, ...} */,
afterUuid /*: string */,
member /*: {uuid: string, comment: string, ...} */,
) /*: string */ {
const field = findField(text, target, 'buildPhases');
if (field == null || field.value.includes(member.uuid)) {
return text;
}
const after = new RegExp(`(^|\\n)([\\t ]*)${afterUuid}\\b[^\\n]*,`).exec(
field.value,
);
if (after == null) {
return addArrayMembers(text, target, 'buildPhases', [member]);
}
const absoluteStart = field.valueStart + after.index;
const lineEnd = text.indexOf('\n', absoluteStart + after[0].length);
const indent = after[2];
const line = `\n${indent}${member.uuid}${uuidComment(member.comment)},`;
return text.slice(0, lineEnd) + line + text.slice(lineEnd);
}
// The node + react-native-dir resolution preamble shared by the sync build
// phase and scheme pre-action. Both dispatch DIRECTLY into react-native's
// scripts rather than through 'npx react-native' — that CLI requires
// @react-native-community/cli (absent in e.g. Expo apps), so it would exit
// non-zero and the failure would be silently swallowed.
function nodeAndRnDirPreamble(reactNativePath /*: string */) /*: string */ {
return `set -euo pipefail
# ---------------------------------------------------------------------------
# Resolve a node binary and the react-native package dir at BUILD TIME.
# ---------------------------------------------------------------------------
NODE_BINARY="\${NODE_BINARY:-}"
if [ -z "$NODE_BINARY" ]; then
# Source RN's standard app-local node-path files. They reference vars that
# may be unset and may return non-zero, so relax nounset AND errexit while
# sourcing — a buggy user .xcode.env must degrade to PATH-based node
# resolution below, not silently abort every build.
set +eu
if [ -f "$SRCROOT/.xcode.env" ]; then
. "$SRCROOT/.xcode.env"
fi
if [ -f "$SRCROOT/.xcode.env.local" ]; then
. "$SRCROOT/.xcode.env.local"
fi
set -eu
NODE_BINARY="\${NODE_BINARY:-}"
fi
if [ -z "$NODE_BINARY" ]; then
NODE_BINARY="$(command -v node 2>/dev/null || true)"
fi
# Resolve react-native's dir FROM THE APP (require.resolve), not a
# generation-time baked path — the baked path goes stale in pnpm / hoisted
# stores. Fall back to the baked path if resolution fails or the resolved dir
# has no setup-apple-spm.js.
RN_DIR=""
if [ -n "$NODE_BINARY" ]; then
RN_DIR="$(cd "$SRCROOT" && "$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json'))" 2>/dev/null || true)"
fi
if [ -z "$RN_DIR" ] || [ ! -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then
RN_DIR="${reactNativePath}"
fi`;
}
// Shared: the STALE-input check + conditional codegen/autolinking sync dispatch.
// Runtime framework slots are never touched here; add/update owns them.
function syncStaleCheckAndDispatch() /*: string */ {
return `STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp"
PLUGIN_MISMATCH="$SRCROOT/build/generated/autolinking/.spm-plugin-mismatch"
PLUGIN_FRAMEWORKS="$SRCROOT/build/generated/autolinking/.spm-plugin-flavored-frameworks.json"
MARKER="\${PROJECT_FILE_PATH:-}/${SPM_INJECTED_MARKER}"
STALE=0
# Find project root (where package.json lives — may be an ancestor of SRCROOT)
PROJECT_ROOT="$SRCROOT"
while [ "$PROJECT_ROOT" != "/" ] && [ ! -f "$PROJECT_ROOT/package.json" ]; do
PROJECT_ROOT="$(dirname "$PROJECT_ROOT")"
done
if [ ! -f "$PROJECT_ROOT/package.json" ]; then
PROJECT_ROOT="$SRCROOT"
fi
# Check 1: dependency inputs (covers app projects after any package manager
# install), and the marker, which records the frameworks the project links.
for INPUT in \\
"$PROJECT_ROOT/package.json" \\
"$PROJECT_ROOT/react-native.config.js" \\
"$MARKER"; do
if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then
STALE=1
break
fi
done
# Check workspace lockfiles and package-manager metadata. These cover package
# managers that do not reliably bump node_modules mtimes, and Yarn PnP projects
# that do not have node_modules at all.
if [ "$STALE" -eq 0 ]; then
DIR="$PROJECT_ROOT"
while [ "$DIR" != "/" ]; do
for INPUT in \\
"$DIR/package-lock.json" \\
"$DIR/npm-shrinkwrap.json" \\
"$DIR/yarn.lock" \\
"$DIR/pnpm-lock.yaml" \\
"$DIR/bun.lock" \\
"$DIR/bun.lockb" \\
"$DIR/.pnp.cjs" \\
"$DIR/.pnp.loader.mjs"; do
if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then
STALE=1
break
fi
done
if [ "$STALE" -eq 1 ]; then
break
fi
DIR="$(dirname "$DIR")"
done
fi
# Check node_modules mtime. In monorepos, node_modules may be hoisted to any
# ancestor between the app package and the workspace root.
if [ "$STALE" -eq 0 ]; then
DIR="$PROJECT_ROOT"
while [ "$DIR" != "/" ]; do
NM_DIR="$DIR/node_modules"
if [ -d "$NM_DIR" ] && [ "$NM_DIR" -nt "$STAMP" ]; then
STALE=1
break
fi
DIR="$(dirname "$DIR")"
done
fi
# Also check the app root directly when SRCROOT is not the package root.
if [ "$STALE" -eq 0 ] && [ "$SRCROOT" != "$PROJECT_ROOT" ]; then
if [ -d "$SRCROOT/node_modules" ] && [ "$SRCROOT/node_modules" -nt "$STAMP" ]; then
STALE=1
fi
fi
# Check 1.5: watched paths (mixed dirs AND files). Dirs catch add/remove of
# source files in spm.modules and autolinked deps (dir mtime updates on both);
# files catch edits to a dep's checked-in Package.swift / plugin manifests that
# would not bump any parent dir mtime. A path that has VANISHED (renamed/moved
# module root) forces a re-sync so the autolinker surfaces the real, actionable
# config error rather than the build failing later on dangling-symlink noise.
WATCH_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-paths"
if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then
while IFS= read -r P; do
[ -z "$P" ] && continue
if [ -d "$P" ]; then
# .swiftpm holds Xcode's own per-user scheme state, which it rewrites
# during a build — reading it as a change makes every IDE build re-sync.
if [ -n "$(find "$P" -name .swiftpm -prune -o -newer "$STAMP" -print -quit 2>/dev/null)" ]; then
STALE=1
break
fi
elif [ -f "$P" ]; then
if [ "$P" -nt "$STAMP" ]; then
STALE=1
break
fi
else
STALE=1
break
fi
done < "$WATCH_FILE"
fi
# Watched paths that did not exist at the last sync. One that APPEARS (e.g. a
# plugin's precompiled output folder) forces a re-sync.
ABSENT_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-absent"
if [ "$STALE" -eq 0 ] && [ -f "$ABSENT_FILE" ]; then
while IFS= read -r P; do
[ -z "$P" ] && continue
if [ -e "$P" ]; then
STALE=1
break
fi
done < "$ABSENT_FILE"
fi
# Check 2: codegen spec files changed via git (covers monorepo after git pull)
if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then
STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
LATEST_SPEC_COMMIT=$(git -C "$SRCROOT" log -1 --format=%ct -- '*.js' '*.ts' 2>/dev/null || echo 0)
if [ "$LATEST_SPEC_COMMIT" -gt "$STAMP_TIME" ]; then
STALE=1
fi
fi
if [ ! -f "$STAMP" ]; then
STALE=1
fi
# The last sync found a plugin framework mismatch and kept its errors. Until an
# input changes, report them again instead of re-running sync: the scheme
# pre-action and this build phase would otherwise both run it. spm add/update
# rewrite the plugin manifest without a sync, so that counts as a change too.
if [ -f "$PLUGIN_MISMATCH" ]; then
if [ "$STALE" -eq 0 ] && \\
[ ! "$MARKER" -nt "$PLUGIN_MISMATCH" ] && \\
[ ! "$PLUGIN_FRAMEWORKS" -nt "$PLUGIN_MISMATCH" ]; then
cat "$PLUGIN_MISMATCH" >&2
exit 1
fi
STALE=1
fi
# Re-sync codegen + autolinking when a dependency input changed. Runtime
# framework slots and Xcode linker settings are only changed by spm update.
if [ "$STALE" -eq 1 ]; then
echo "SPM sync inputs changed — re-syncing (codegen + autolinking)..."
WITH_ENVIRONMENT="$RN_DIR/scripts/xcode/with-environment.sh"
if [ -f "$WITH_ENVIRONMENT" ]; then
# with-environment.sh references PODS_ROOT and $1, which may be unset.
# Temporarily disable nounset to avoid failures when sourcing.
export PODS_ROOT="\${PODS_ROOT:-$SRCROOT}"
set +u
. "$WITH_ENVIRONMENT"
set -u
fi
cd "$SRCROOT"
# \`|| RC=$?\` so a non-zero exit is CAPTURED rather than aborting the phase
# under \`set -e\` — the whole point is to branch on the code below (2 = fail
# the build; other non-zero = warn but don't break).
RC=0
if [ -n "$NODE_BINARY" ] && [ -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then
# Direct, dependency-free dispatch (no \`npx react-native\`, which needs
# @react-native-community/cli).
"$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync || RC=$?
elif command -v npx >/dev/null 2>&1; then
npx react-native spm sync || RC=$?
else
echo "warning: node/npx not found — skipping SPM sync"
fi
if [ "$RC" -eq 2 ]; then
# Exit 2 = a problem only a terminal command fixes (e.g. a dependency
# without Package.swift, or plugin frameworks the project does not link).
# Sync already printed \`error:\` lines that name the command, so Xcode
# shows them. Fail the build.
exit 1
elif [ "$RC" -ne 0 ]; then
echo "warning: SPM sync failed — build may use stale codegen/autolinking"
fi
fi
`;
}
// Scheme pre-action: re-run codegen + autolinking before package resolution.
function buildSchemePreActionScript(
reactNativePath /*: string */,
) /*: string */ {
return `${nodeAndRnDirPreamble(reactNativePath)}
${syncStaleCheckAndDispatch()}
`;
}
// The in-target phase is only an autolinking safety net. Runtime framework
// selection is expressed entirely through build settings and the independent
// Embed React Native Flavored Frameworks phase.
function buildSyncAutolinkingScript(
reactNativePath /*: string */,
) /*: string */ {
return `${nodeAndRnDirPreamble(reactNativePath)}
${syncStaleCheckAndDispatch()}
`;
}
// XML-attribute escape. The sync script uses `>` and `&` for redirection and
// bg/and chains, plus `<` for heredocs and comparisons — all of which break
// Xcode's scheme parser if left raw. Raw line breaks and tabs are legal but an
// XML parser turns them into spaces (XML 1.0 §3.3.3), collapsing the script
// onto one line, so they are written as character references, as Xcode does.
function escapeXmlAttribute(s /*: string */) /*: string */ {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\t/g, '	');
}
function generateXcscheme(
appName /*: string */,
targetUUID /*: string */,
projName /*: string */,
syncScript /*: string */,
) /*: string */ {
const escapedSync = escapeXmlAttribute(syncScript);
return `<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Sync SPM Autolinking"
scriptText = "${escapedSync}"
shellToInvoke = "/bin/bash">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "${targetUUID}"
BuildableName = "${appName}.app"
BlueprintName = "${appName}"
ReferencedContainer = "container:${projName}.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "${targetUUID}"
BuildableName = "${appName}.app"
BlueprintName = "${appName}"
ReferencedContainer = "container:${projName}.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "${targetUUID}"
BuildableName = "${appName}.app"
BlueprintName = "${appName}"
ReferencedContainer = "container:${projName}.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "${targetUUID}"
BuildableName = "${appName}.app"
BlueprintName = "${appName}"
ReferencedContainer = "container:${projName}.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
`;
}
// When the xcodeproj is generated, the referenced SPM package directories
// (build/xcframeworks, autolinked, build/generated/ios) may not exist yet.
// Xcode resolves packages before any build phase runs, so we write minimal
// stub Package.swift files to let resolution succeed. The real generators
// (sync-spm-autolinking.js) overwrite these during the first build.
/*::
type StubPackageDef = {
packageName: string,
products: Array<string>,
};
*/
function generateStubPackageSwift(def /*: StubPackageDef */) /*: string */ {
const {packageName, products} = def;
const stubTarget = `${packageName.replace(/[^a-zA-Z0-9]/g, '')}Stub`;
const productLines = products
.map(p => ` .library(name: "${p}", targets: ["${stubTarget}"]),`)
.join('\n');
return `// swift-tools-version: 5.9
// GENERATED STUB — will be overwritten by sync-spm-autolinking.js during build.
import PackageDescription
let package = Package(
name: "${packageName}",
products: [
${productLines}
],
targets: [
.target(name: "${stubTarget}", path: "_stub", sources: ["Stub.swift"]),
]
)
`;
}
/**
* Ensures each referenced SPM sub-package directory has a valid Package.swift
* so Xcode can resolve packages before any build phase runs.
* Skips directories that already contain a Package.swift (from a previous build).
*/
function ensureStubPackages(appRoot /*: string */) /*: void */ {
// Derive stub definitions from SPM_PRODUCT_PACKAGES
const byPath = new Map /*:: <string, StubPackageDef> */();
for (const entry of SPM_PRODUCT_PACKAGES) {
const existing = byPath.get(entry.packagePath);
if (existing != null) {
existing.products.push(entry.product);
} else {
byPath.set(entry.packagePath, {
packageName: entry.packageName,
products: [entry.product],
});
}
}
// Library manifests depend on these products, but the app must not link
// them, so they are stubbed here instead of listed in SPM_PRODUCT_PACKAGES.
for (const def of byPath.values()) {
if (def.packageName === REACT_CODEGEN_PACKAGE_NAME) {
for (const product of REACT_CODEGEN_PRODUCTS) {
if (!def.products.includes(product)) {
def.products.push(product);
}
}
}
}
for (const [relPath, def] of byPath) {
const pkgDir = path.join(appRoot, relPath);
const pkgSwiftPath = path.join(pkgDir, 'Package.swift');
if (fs.existsSync(pkgSwiftPath)) {
continue;
}
fs.mkdirSync(pkgDir, {recursive: true});
fs.writeFileSync(pkgSwiftPath, generateStubPackageSwift(def), 'utf8');
// Create minimal stub source file required by SPM
const stubDir = path.join(pkgDir, '_stub');
fs.mkdirSync(stubDir, {recursive: true});
const stubSwift = path.join(stubDir, 'Stub.swift');
if (!fs.existsSync(stubSwift)) {
fs.writeFileSync(
stubSwift,
'// Placeholder — replaced during first build.\n',
'utf8',
);
}
log(`Wrote stub Package.swift: ${relPath}/Package.swift`);
}
}
// ---------------------------------------------------------------------------
// In-place injection: add SPM packages to a user's EXISTING xcodeproj.
//
// This never creates a target or scans sources — it splices the SPM dependency
// graph, a reference to the generated xcconfig carrying the React build
// settings, and the sync build phase / scheme pre-action into the project the
// user already owns, leaving everything else
// byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so
// hand-tuned signing / capabilities / extra targets survive. Fails loud (the
// caller surfaces the error) when the project is CocoaPods-integrated or its
// shape can't be safely anchored.
// ---------------------------------------------------------------------------
// The React list settings the app target needs to compile against the SPM
// products, in xcconfig syntax.
const REACT_LIST_SETTINGS = [
{
key: 'HEADER_SEARCH_PATHS',
values: ['"$(SRCROOT)/build/generated/autolinking/headers"'],
},
];
// List settings given only to debug-flavored configurations.
//
// Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by
// GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app
// template does not commit the setting: CocoaPods injects it at `pod install`
// time (react_native_post_install → set_build_setting
// SWIFT_ACTIVE_COMPILATION_CONDITIONS = ["$(inherited)", "DEBUG"] on Debug).
// An SPM app never runs CocoaPods, so without this `#if DEBUG` is false even
// in a Debug build — AppDelegate.swift's `bundleURL()` skips the Metro URL,
// falls back to a main.jsbundle that a Debug build never produced, and the app
// dies at launch with "No script url provided … unsanitizedScriptURLString =
// (null)" while Metro is running right there.
//
// Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that
// links the debug xcframeworks also compiles its Swift with DEBUG.
const DEBUG_LIST_SETTINGS = [
{key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']},
];
/**
* The generated xcconfig every app configuration in `configNames` is based on:
* all of the React build settings, shared by every configuration, with the
* flavor-specific ones conditioned on each configuration's own name. List
* settings extend `$(inherited)`, the project-level value; a target's own
* value still outranks all of them (see listSettingsHidingXcconfig).
*/
function generateReactNativeXcconfig(
configNames /*: ReadonlyArray<string> */,
reactNativePathFromSrcRoot /*: string */,
flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
) /*: string */ {
const listLine = (key /*: string */, values /*: ReadonlyArray<string> */) =>
`${key} = ${['$(inherited)', ...values].join(' ')}`;
const sections /*: Array<Array<string>> */ = [
[
'// Generated by React Native on every `spm add` and `spm update`.',
'// Do not edit: changes are overwritten.',
],
[
'CLANG_CXX_LANGUAGE_STANDARD = c++20',
`REACT_NATIVE_PATH = ${['$(SRCROOT)', reactNativePathFromSrcRoot].filter(Boolean).join('/')}`,
],
[...REACT_LIST_SETTINGS, ...frameworkListSettings(flavoredFrameworks)]
.filter(({values}) => values.length > 0)
.map(({key, values}) => listLine(key, values)),
[
// For a configuration added since the last sync (duplicated in Xcode,
// which copies the base reference): flavorForBuildConfiguration's own
// fallback, rather than an unset flavor that empties every framework
// path.
'RN_SPM_FLAVOR = release',
...configNames.flatMap(name => {
const flavor = flavorForBuildConfiguration(name);
return [
`RN_SPM_FLAVOR[config=${name}] = ${flavor}`,
...(flavor === 'debug' ? DEBUG_LIST_SETTINGS : []).map(
({key, values}) => listLine(`${key}[config=${name}]`, values),
),
];
}),
],
frameworkConditionalSettings(flavoredFrameworks).map(
({key, value}) => `${key} = ${value}`,
),
];
return (
sections
.filter(lines => lines.length > 0)
.map(lines => lines.join('\n'))
.join('\n\n') + '\n'
);
}
function xcconfigFileRefUuid(rootUuid /*: string */) /*: string */ {
return namespacedUUID(rootUuid, 'PBXFileReference', GENERATED_XCCONFIG_PATH);
}
/** A build configuration's `buildSettings` dict, as a body range. */
function buildSettingsRange(
text /*: string */,
configUuid /*: string */,
) /*: ?{bodyOpen: number, bodyClose: number} */ {
const config = findObjectByUuid(text, configUuid);
const settings =
config != null ? findField(text, config, 'buildSettings') : null;
return settings != null
? {bodyOpen: settings.valueStart, bodyClose: settings.tokenEnd - 1}
: null;
}
/**
* The list settings the generated xcconfig sets that a configuration of the
* target sets itself without `$(inherited)` — setting → configuration names. A
* target's own value outranks the xcconfig it is based on, so each of these
* would silently drop the value React Native provides.
*/
function listSettingsHidingXcconfig(
text /*: string */,
configUuids /*: ReadonlyArray<string> */,
) /*: Map<string, Array<string>> */ {
const hiding /*: Map<string, Array<string>> */ = new Map();
for (const configUuid of configUuids) {
const name = buildConfigurationName(text, configUuid);
const settings = buildSettingsRange(text, configUuid);
if (settings == null) {
continue;
}
const keys = [
...REACT_LIST_SETTINGS,
...frameworkListSettings([]),
...(flavorForBuildConfiguration(name) === 'debug'
? DEBUG_LIST_SETTINGS
: []),
].map(({key}) => key);
for (const key of keys) {
const field = findField(text, settings, key);
if (field != null && !/\$[({]inherited[)}]/.test(field.value)) {
hiding.set(key, [...(hiding.get(key) ?? []), name]);
}
}
}
return hiding;
}
// Xcode writes a PBXFileReference on one line, which findField can't read.
function fileReferenceField(
text /*: string */,
ref /*: {bodyOpen: number, bodyClose: number, ...} */,
key /*: string */,
) /*: ?string */ {
const value = new RegExp(
`[\\s{;]${key} = ("(?:[^"\\\\]|\\\\.)*"|[^;]*);`,
).exec(text.slice(ref.bodyOpen, ref.bodyClose))?.[1];
return value != null ? unquotePlist(value) : null;
}
/**
* The app configurations based on an xcconfig other than the generated one —
* React Native never edits an xcconfig it does not own, so it cannot base
* those configurations on its own. `file` is the reference's path, and
* `absPath`/`content` the file on disk when it can be read.
*/
function foreignBaseConfigurations(
text /*: string */,
configUuids /*: ReadonlyArray<string> */,
ownFileRefUuid /*: string */,
srcRoot /*: ?string */,
) /*: Array<{configUuid: string, config: string, file: string, onDisk: ?{absPath: string, content: string}}> */ {
const foreign = [];
for (const configUuid of configUuids) {
const config = findObjectByUuid(text, configUuid);
const base =
config != null
? findField(text, config, 'baseConfigurationReference')
: null;
const refUuid = base?.value.match(/[0-9A-Fa-f]{24}/)?.[0];
if (refUuid == null || refUuid === ownFileRefUuid) {
continue;
}
const ref = findObjectByUuid(text, refUuid);
const refPath = ref != null ? fileReferenceField(text, ref, 'path') : null;
let onDisk /*: ?{absPath: string, content: string} */ = null;
if (ref != null && refPath != null && srcRoot != null) {
const candidates = xcconfigReferencePaths(
text,
{
uuid: refUuid,
path: refPath,
sourceTree: fileReferenceField(text, ref, 'sourceTree') ?? '',
},
srcRoot,
);
for (const absPath of candidates) {
const content = readXcconfig(absPath);
if (content != null) {
onDisk = {absPath, content};
break;
}
}
}
foreign.push({
configUuid,
config: buildConfigurationName(text, configUuid),
file: refPath ?? refUuid,
onDisk,
});
}
return foreign;
}
/** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */
function targetBuildConfigUuids(
text /*: string */,
targetObj /*: {bodyOpen: number, bodyClose: number, ...} */,
) /*: Array<string> */ {
const listField = findField(text, targetObj, 'buildConfigurationList');
if (listField == null) {
return [];
}
const listMatch = listField.value.match(/[0-9A-Fa-f]{24}/);
if (listMatch == null) {
return [];
}
const listObj = findObjectByUuid(text, listMatch[0]);
if (listObj == null) {
return [];
}
const configs = findField(text, listObj, 'buildConfigurations');
if (configs == null) {
return [];
}
const matches = configs.value.match(/[0-9A-Fa-f]{24}/g);
return matches != null ? Array.from(matches) : [];
}
/** True when a build config layers a CocoaPods `Pods-*.xcconfig`. */
function configUsesPods(
text /*: string */,
configUuid /*: string */,
) /*: boolean */ {
const obj = findObjectByUuid(text, configUuid);
if (obj == null) {
return false;
}
const base = findField(text, obj, 'baseConfigurationReference');
return base != null && /Pods[-/]/.test(base.value);
}
/**
* Inspect an existing pbxproj and decide whether it can be injected. Returns
* the chosen app target + its config/frameworks anchors, or a refusal reason
* the caller surfaces (fail-loud).
*/
function planInjection(
text /*: string */,
opts /*: {appName?: ?string, appRoot?: ?string, srcRoot?: ?string} */,
) /*:
| {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array<string>, includedByConfigUuids: Array<string>, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string}
| {ok: false, reason: string} */ {
const project = findProjectObject(text);
if (project == null) {
return {ok: false, reason: 'no PBXProject object found'};
}
const apps = findApplicationTargets(text);
if (apps.length === 0) {
return {ok: false, reason: 'no application target found'};
}
let target;
if (apps.length === 1) {
target = apps[0];
} else {
const appName = opts.appName;
if (appName == null) {
return {
ok: false,
reason: `multiple application targets (${apps
.map(a => a.name)
.join(', ')}); pass --app-name to disambiguate`,
};
}
target = apps.find(a => a.name === appName);
if (target == null) {
return {
ok: false,
reason: `no application target named "${appName}"`,
};
}
}
const configUuids = targetBuildConfigUuids(text, target);
if (configUuids.length === 0) {
return {ok: false, reason: 'could not resolve target build configurations'};
}
if (configUuids.some(c => configUsesPods(text, c))) {
return {
ok: false,
reason:
'target uses CocoaPods (Pods-*.xcconfig) — in-place injection only ' +
'supports SPM-only targets',
};
}
const generatedXcconfig =
opts.appRoot != null
? path.resolve(opts.appRoot, GENERATED_XCCONFIG_PATH)
: null;
const includedByConfigUuids /*: Array<string> */ = [];
for (const foreign of foreignBaseConfigurations(
text,
configUuids,
xcconfigFileRefUuid(project.uuid),
opts.srcRoot,
)) {
const {onDisk} = foreign;
if (
onDisk != null &&
generatedXcconfig != null &&
xcconfigIncludes(onDisk.absPath, onDisk.content, generatedXcconfig)
) {
includedByConfigUuids.push(foreign.configUuid);
continue;
}
const fix =
onDisk != null && generatedXcconfig != null
? `add #include "${path
.relative(path.dirname(onDisk.absPath), generatedXcconfig)
.split(path.sep)
.join('/')}" to ${foreign.file} instead`
: `#include ${GENERATED_XCCONFIG_PATH} from ${foreign.file} instead, ` +
`with the path relative to the folder that holds ${foreign.file}`;
return {
ok: false,
reason:
`the ${foreign.config} configuration is already based on ` +
`${foreign.file}, and React Native does not edit xcconfig files it ` +
`does not own — ${fix}`,
};
}
const hiding = listSettingsHidingXcconfig(text, configUuids);
if (hiding.size > 0) {
const listed = [...hiding]
.map(([key, configs]) => `${key} (${configs.join(', ')})`)
.join(', ');
return {
ok: false,
reason:
`the target sets ${listed} without $(inherited), which would hide ` +
`the values React Native provides in ${GENERATED_XCCONFIG_PATH} — ` +
'add $(inherited) to each and re-run',
};
}
// The target's own Frameworks build phase (where product build files link).
const buildPhases = findField(text, target, 'buildPhases');
const phaseUuids =
buildPhases != null
? (buildPhases.value.match(/[0-9A-Fa-f]{24}/g) ?? [])
: [];
let frameworksPhaseUuid = null;
// Also capture the Sources phase — plugin generated sources compile into it
// (see injectSpmIntoPbxproj step 8). Nullable: a target may legitimately
// lack one, in which case generated-source wiring is skipped (not fatal).
let sourcesPhaseUuid = null;
for (const pu of phaseUuids) {
const po = findObjectByUuid(text, pu);
if (po == null) {
continue;
}
const isa = findField(text, po, 'isa');
if (isa == null) {
continue;
}
if (
frameworksPhaseUuid == null &&
/PBXFrameworksBuildPhase/.test(isa.value)
) {
frameworksPhaseUuid = pu;
} else if (
sourcesPhaseUuid == null &&
/PBXSourcesBuildPhase/.test(isa.value)
) {
sourcesPhaseUuid = pu;
}
}
if (frameworksPhaseUuid == null) {
return {ok: false, reason: 'target has no Frameworks build phase'};
}
return {
ok: true,
rootUuid: project.uuid,
target,
configUuids,
includedByConfigUuids,
frameworksPhaseUuid,
sourcesPhaseUuid,
};
}
/**
* Splice the SPM dependency graph, the generated xcconfig reference and the
* build phases into `text`, returning the modified pbxproj together with the
* files it references (`generatedFiles`, app-root-relative path → content) for
* the caller to write. Pure string transform (no I/O), idempotent: objects
* already present (by UUID) and array members / fields already applied are
* skipped, so a second run is a no-op.
*/
function injectSpmIntoPbxproj(
input /*: string */,
plan /*: {rootUuid: string, targetUuid: string, configUuids: Array<string>, includedByConfigUuids?: ?ReadonlyArray<string>, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */,
reactNativePaths /*: {fromAppRoot: string, fromSrcRoot: string} */,
remote /*: ?RemoteCfg */,
generatedSources /*: ReadonlyArray<GeneratedSource> */ = [],
flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
scriptPhases /*: ReadonlyArray<PluginScriptPhase> */ = [],
) /*: {text: string, injectedUuids: Array<string>, createdArrayFields: Array<CreatedArrayField>, xcconfig: XcconfigRecord, generatedFiles: {[string]: string}, generatedSourceUuids: {[string]: Array<string>}, scriptPhaseUuids: {[string]: string}} */ {
let text = input;
const mkUuid = (section /*: string */, id /*: string */) =>
namespacedUUID(plan.rootUuid, section, id);
const graph = buildSpmDependencyGraph(mkUuid, remote);
const entries = spmGraphToEntries(graph);
const injectedUuids /*: Array<string> */ = [];
// 1. Insert the new objects (skip any UUID already present — idempotency).
const insertObjects = (
sectionName /*: string */,
objs /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...}> */,
) => {
const fresh = objs.filter(o => !text.includes(o.uuid));
for (const o of objs) {
injectedUuids.push(o.uuid);
}
if (fresh.length === 0) {
return;
}
text = insertObjectsIntoSection(
text,
sectionName,
fresh.map(serializeEntry).join('\n'),
);
};
insertObjects('XCLocalSwiftPackageReference', entries.localRefs);
if (entries.remoteRef != null) {
insertObjects('XCRemoteSwiftPackageReference', [entries.remoteRef]);
}
insertObjects('XCSwiftPackageProductDependency', entries.productDeps);
insertObjects('PBXBuildFile', entries.buildFiles);
const createdArrayFields /*: Array<CreatedArrayField> */ = [];
// 2. packageReferences on the PBXProject.
const pkgRefMembers = [
...(graph.remotePkgRef != null
? [{uuid: graph.remotePkgRef.uuid, comment: graph.remotePkgRef.comment}]
: []),
...graph.localPkgRefs.map(r => ({uuid: r.uuid, comment: r.comment})),
];
const project = findProjectObject(text);
if (project != null) {
if (findField(text, project, 'packageReferences') == null) {
createdArrayFields.push({container: 'project', key: 'packageReferences'});
}
text = addArrayMembers(text, project, 'packageReferences', pkgRefMembers);
}
// 3. packageProductDependencies on the app target.
const productMembers = graph.products.map(p => ({
uuid: p.depUuid,
comment: p.product,
}));
if (
findField(
text,
findApplicationTargetByUuid(text, plan.targetUuid),
'packageProductDependencies',
) == null
) {
createdArrayFields.push({
container: 'target',
key: 'packageProductDependencies',
});
}
text = addArrayMembers(
text,
findApplicationTargetByUuid(text, plan.targetUuid),
'packageProductDependencies',
productMembers,
);
// 4. product build files into the target's Frameworks phase.
const phase = findObjectByUuid(text, plan.frameworksPhaseUuid);
if (phase != null) {
text = addArrayMembers(
text,
phase,
'files',
graph.products.map(p => ({
uuid: p.buildFileUuid,
comment: `${p.product} in Frameworks`,
})),
);
}
// 5. Every app configuration is based on the generated xcconfig, which
// carries all React build settings — except one whose own xcconfig
// already #includes it. planInjection refused any other base
// configuration, so an existing one here is already ours.
const xcconfigName = path.basename(GENERATED_XCCONFIG_PATH);
const xcconfigRefUuid = xcconfigFileRefUuid(plan.rootUuid);
insertObjects('PBXFileReference', [
{
uuid: xcconfigRefUuid,
comment: xcconfigName,
fields: {
isa: 'PBXFileReference',
lastKnownFileType: 'text.xcconfig',
name: xcconfigName,
path: GENERATED_XCCONFIG_PATH,
sourceTree: 'SOURCE_ROOT',
},
},
]);
const xcconfigGroup = mainGroupObject(text);
if (xcconfigGroup != null) {
text = addArrayMembers(text, xcconfigGroup, 'children', [
{uuid: xcconfigRefUuid, comment: xcconfigName},
]);
}
const includedBy = new Set(plan.includedByConfigUuids ?? []);
let removedPodsRootReactNativePath = false;
for (const configUuid of plan.configUuids) {
const config = findObjectByUuid(text, configUuid);
if (config != null && !includedBy.has(configUuid)) {
text = ensureScalarField(
text,
config,
'baseConfigurationReference',
`${xcconfigRefUuid}${uuidComment(xcconfigName)}`,
{after: 'isa'},
);
}
// The CocoaPods template's REACT_NATIVE_PATH is ${PODS_ROOT}-anchored, which
// resolves empty once CocoaPods is gone — and a target's own value outranks
// the xcconfig's, so it has to go for the working one to apply.
const settings = buildSettingsRange(text, configUuid);
if (
settings != null &&
findField(text, settings, 'REACT_NATIVE_PATH')?.value.includes(
'PODS_ROOT',
) === true
) {
text = removeField(text, settings, 'REACT_NATIVE_PATH');
removedPodsRootReactNativePath = true;
}
}
const generatedFiles /*: {[string]: string} */ = {
[GENERATED_XCCONFIG_PATH]: generateReactNativeXcconfig(
plan.configUuids.map(uuid => buildConfigurationName(text, uuid)),
reactNativePaths.fromSrcRoot,
flavoredFrameworks,
),
[SYNC_AUTOLINKING_SCRIPT_PATH]: generatedScript(
buildSyncAutolinkingScript(reactNativePaths.fromAppRoot),
),
[EMBED_FRAMEWORKS_SCRIPT_PATH]: generatedScript(
buildEmbedFrameworksScript(flavoredFrameworks),
),
};
// 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action
// is what fires before SPM resolution). Prepended so it runs before
// Sources. We do NOT add a JS-bundle phase — an existing app already
// bundles JS via its own phase.
const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking');
const syncEntry = shellScriptPhase(
syncPhaseUuid,
'Sync SPM Autolinking',
generatedScriptWrapper(SYNC_AUTOLINKING_SCRIPT_PATH),
);
if (!text.includes(syncPhaseUuid)) {
text = insertObjectsIntoSection(
text,
'PBXShellScriptBuildPhase',
serializeEntry(syncEntry),
);
} else {
// Already injected on a prior run — the phase object owns these fields,
// so refresh them in place (same quoting used at creation) in case a
// project injected before the script moved to its own file still carries
// it inline, or one injected before shellPath moved to bash (see
// shellScriptPhase) still has the stale value. Byte-identical when neither
// did; field order and every other byte of the phase are untouched.
for (const key of ['shellScript', 'shellPath']) {
const current = findObjectByUuid(text, syncPhaseUuid);
if (current != null) {
text = setScalarField(text, current, key, syncEntry.fields[key]);
}
}
}
injectedUuids.push(syncPhaseUuid);
text = addArrayMembers(
text,
findApplicationTargetByUuid(text, plan.targetUuid),
'buildPhases',
[{uuid: syncPhaseUuid, comment: 'Sync SPM Autolinking'}],
{prepend: true},
);
// 7. The sole writer of flavored frameworks under the final app bundle.
// SwiftPM owns only invariant header/source products, so no implicit SPM
// embed task competes with this phase.
const embedPhaseUuid = mkUuid(
'PBXShellScriptBuildPhase',
'EmbedFlavoredFrameworks',
);
const embedInputs = [
'$(SRCROOT)/build/xcframeworks/.artifact-stamp',
`$(SRCROOT)/${EMBED_FRAMEWORKS_SCRIPT_PATH}`,
...flavoredFrameworks.map(
framework => `$(${frameworkSettingPrefix(framework.id)}_FRAMEWORK)`,
),
];
const embedOutputs = flavoredFrameworks.map(
framework =>
`$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/${framework.frameworkName}.framework`,
);
const embedEntry = shellScriptPhase(
embedPhaseUuid,
'Embed React Native Flavored Frameworks',
generatedScriptWrapper(EMBED_FRAMEWORKS_SCRIPT_PATH),
{
inputPaths: pbxPathList(embedInputs),
outputPaths: pbxPathList(embedOutputs),
},
);
if (!text.includes(embedPhaseUuid)) {
text = insertObjectsIntoSection(
text,
'PBXShellScriptBuildPhase',
serializeEntry(embedEntry),
);
} else {
const existingPhase = findObjectByUuid(text, embedPhaseUuid);
if (existingPhase != null) {
for (const key of [
'shellScript',
'inputPaths',
'outputPaths',
'shellPath',
]) {
const current = findObjectByUuid(text, embedPhaseUuid);
if (current != null) {
text = setScalarField(text, current, key, embedEntry.fields[key]);
}
}
}
}
injectedUuids.push(embedPhaseUuid);
text = addBuildPhaseAfter(
text,
findApplicationTargetByUuid(text, plan.targetUuid),
plan.frameworksPhaseUuid,
{
uuid: embedPhaseUuid,
comment: 'Embed React Native Flavored Frameworks',
},
);
// 8. Plugin generated sources compiled INTO THE APP TARGET (e.g. Expo's
// ExpoModulesProvider.swift). An `@objc` class only reaches the ObjC
// classlist — required for NSClassFromString discovery — when it compiles
// into the app target, not the static Autolinked aggregate. Each source
// gets a PBXFileReference + PBXBuildFile + a Sources-phase entry, parented
// under a single "SPM Generated Sources" group. Every UUID is keyed on the
// normalized path (deterministic → idempotent) and recorded so `deinit`
// reverts it and `update` reconciles it (removal is done by the caller,
// which owns the prior marker; emission here is purely additive).
const generatedSourceUuids /*: {[string]: Array<string>} */ = {};
const sourcesPhaseUuid = plan.sourcesPhaseUuid;
if (generatedSources.length > 0) {
if (sourcesPhaseUuid == null) {
log(
'warning: the app target has no Sources build phase — cannot compile ' +
`${generatedSources.length} SPM plugin generated source(s) into the ` +
'app target; skipping. Any @objc classes they define will not be ' +
'discoverable via NSClassFromString.',
);
} else {
const fileRefs = [];
const buildFiles = [];
const sourcesMembers = [];
const groupChildren = [];
for (const src of generatedSources) {
const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`);
const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`);
generatedSourceUuids[src.path] = [fileRefUuid, buildFileUuid];
// The name/path VALUES stay verbatim (escaped) — only the cosmetic
// comments are normalized, falling back to the normalized path (the
// ledger key) and then to no comment at all, which is well-formed.
const label = commentSafe(src.name) || commentSafe(src.path);
const inSources = label === '' ? '' : `${label} in Sources`;
fileRefs.push({
uuid: fileRefUuid,
comment: label,
fields: {
isa: 'PBXFileReference',
lastKnownFileType: src.fileType,
name: quoteIfNeeded(src.name),
path: quoteIfNeeded(src.path),
sourceTree: src.sourceTree,
},
});
buildFiles.push({
uuid: buildFileUuid,
comment: inSources,
fields: {
isa: 'PBXBuildFile',
fileRef: `${fileRefUuid}${uuidComment(label)}`,
},
});
sourcesMembers.push({uuid: buildFileUuid, comment: inSources});
groupChildren.push({uuid: fileRefUuid, comment: label});
}
insertObjects('PBXFileReference', fileRefs);
insertObjects('PBXBuildFile', buildFiles);
// Compile membership — the actual reason these are wired into the app.
const sourcesPhase = findObjectByUuid(text, sourcesPhaseUuid);
if (sourcesPhase != null) {
text = addArrayMembers(text, sourcesPhase, 'files', sourcesMembers);
}
// The "SPM Generated Sources" group (created on first use, then reused).
// Insert with empty children so the ONE population path (addArrayMembers)
// handles both create and reconcile, keeping formatting identical.
const groupUuid = mkUuid('PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID);
if (!text.includes(groupUuid)) {
text = insertObjectsIntoSection(
text,
'PBXGroup',
serializeEntry({
uuid: groupUuid,
comment: SPM_GENERATED_SOURCES_GROUP_NAME,
fields: {
isa: 'PBXGroup',
children: '(\n\t\t\t)',
name: quoteIfNeeded(SPM_GENERATED_SOURCES_GROUP_NAME),
sourceTree: '"<group>"',
},
}),
);
}
injectedUuids.push(groupUuid);
const groupObj = findObjectByUuid(text, groupUuid);
if (groupObj != null) {
text = addArrayMembers(text, groupObj, 'children', groupChildren);
}
// Parent the group under the project's main group (idempotent). Appends
// to a pre-existing children array, so no createdArrayField is recorded —
// deinit removes the group's membership via removeArrayMembersByUuid and
// the group object itself via removeObjectByUuid (groupUuid is injected).
const mainGroupObj = mainGroupObject(text);
if (mainGroupObj != null) {
text = addArrayMembers(text, mainGroupObj, 'children', [
{uuid: groupUuid, comment: SPM_GENERATED_SOURCES_GROUP_NAME},
]);
}
}
}
// 9. Plugin-declared build phases (SwiftPM has no `script_phase`). Each
// phase's UUID is keyed on its plugin id, so re-runs refresh in place and
// `deinit` reverses it.
const scriptPhaseUuids /*: {[string]: string} */ = {};
if (scriptPhases.length > 0) {
const seated /*: Array<SeatedPhase> */ = [];
for (const scriptPhase of scriptPhases) {
const uuid = mkUuid(
'PBXShellScriptBuildPhase',
`plugin:${scriptPhase.id}`,
);
scriptPhaseUuids[scriptPhase.id] = uuid;
// The full name goes into the `name` field (escaped) — that is what Xcode
// displays. The cosmetic comments get it normalized, falling back to the
// normalized id and then to no comment at all, which is well-formed. The
// fallback is sanitized rather than trusted to the id charset, so widening
// that charset can never reach a comment.
const comment =
commentSafe(scriptPhase.name) || commentSafe(scriptPhase.id);
const entry = shellScriptPhase(
uuid,
scriptPhase.name,
scriptPhase.script,
{
inputPaths: scriptPhase.inputPaths,
outputPaths: scriptPhase.outputPaths,
alwaysOutOfDate: scriptPhase.alwaysOutOfDate,
comment,
},
);
if (!text.includes(uuid)) {
text = insertObjectsIntoSection(
text,
'PBXShellScriptBuildPhase',
serializeEntry(entry),
);
} else {
// Rewrite the fields we own unconditionally — byte-identical when the
// plugin's declaration hasn't changed, so no content hashing is needed.
// Each write shifts offsets, hence the re-lookup per field.
for (const key of [
'name',
'shellScript',
'inputPaths',
'outputPaths',
'shellPath',
]) {
const current = findObjectByUuid(text, uuid);
if (current != null) {
text = setScalarField(text, current, key, entry.fields[key]);
}
}
// Flipping alwaysOutOfDate back off means REMOVING the field —
// setScalarField would only ever write a value.
const current = findObjectByUuid(text, uuid);
if (current != null) {
text =
scriptPhase.alwaysOutOfDate === true
? setScalarField(text, current, 'alwaysOutOfDate', '1')
: removeField(text, current, 'alwaysOutOfDate');
}
}
injectedUuids.push(uuid);
text = setUuidComment(text, uuid, comment);
seated.push({uuid, comment, position: scriptPhase.position});
}
text = seatScriptPhases(
text,
plan.targetUuid,
syncPhaseUuid,
seated,
sourcesPhaseUuid,
);
}
return {
text,
injectedUuids,
createdArrayFields,
xcconfig: {
fileRefUuid: xcconfigRefUuid,
configUuids: plan.configUuids.filter(uuid => !includedBy.has(uuid)),
includedByConfigUuids: [...includedBy],
removedPodsRootReactNativePath,
},
generatedFiles,
generatedSourceUuids,
scriptPhaseUuids,
};
}
function mainGroupObject(
text /*: string */,
) /*: ?{uuid: string, bodyOpen: number, bodyClose: number} */ {
const project = findProjectObject(text);
const mainGroup =
project != null ? findField(text, project, 'mainGroup') : null;
const uuid = mainGroup?.value.match(/[0-9A-Fa-f]{24}/)?.[0];
return uuid != null ? findObjectByUuid(text, uuid) : null;
}
/** Re-locate an application target by UUID against the current text. */
function findApplicationTargetByUuid(
text /*: string */,
targetUuid /*: string */,
) /*: {uuid: string, bodyOpen: number, bodyClose: number} */ {
const obj = findObjectByUuid(text, targetUuid);
if (obj == null) {
throw new Error(`pbxproj: app target ${targetUuid} disappeared mid-edit`);
}
return obj;
}
/** Strip the surrounding plist quotes from a token, if any. */
function unquotePlist(s /*: string */) /*: string */ {
return s.replace(/^"/, '').replace(/"$/, '');
}
// Write only when content changed (avoids spurious Xcode reloads / git churn).
function writeIfChanged(
filePath /*: string */,
content /*: string */,
) /*: boolean */ {
fs.mkdirSync(path.dirname(filePath), {recursive: true});
try {
if (fs.readFileSync(filePath, 'utf8') === content) {
return false;
}
} catch {
/* file doesn't exist yet */
}
fs.writeFileSync(filePath, content, 'utf8');
return true;
}
/**
* Add the "Sync SPM Autolinking" pre-action to an existing scheme's
* BuildAction, reusing the scheme's own primary BuildableReference. Returns
* the XML unchanged when the pre-action is already present.
*/
function addPreActionToScheme(
xml /*: string */,
targetUuid /*: string */,
syncScript /*: string */,
) /*: string */ {
const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"');
if (titleIdx >= 0) {
// Already injected on a prior run — refresh a possibly-stale scriptText
// in place (same escaping used at creation) rather than leaving it
// forever. Splice by index (not a regex/string replace) since the script
// itself may contain `$`-sequences that String.replace's replacement-
// pattern syntax would otherwise misinterpret. Byte-identical when the
// script is unchanged; every other byte of the scheme is untouched.
const scriptTextMarker = 'scriptText = "';
const stIdx = xml.indexOf(scriptTextMarker, titleIdx);
if (stIdx < 0) {
return xml; // malformed — leave untouched rather than guess
}
const valueStart = stIdx + scriptTextMarker.length;
// escapeXmlAttribute maps a literal `"` to `"`, so the attribute
// value itself never contains one — the next `"` is always the closing
// delimiter.
const valueEnd = xml.indexOf('"', valueStart);
let xmlWithScript =
xml.slice(0, valueStart) +
escapeXmlAttribute(syncScript) +
xml.slice(valueEnd);
// Xcode always runs a scheme pre-action's scriptText under the shell this
// attribute names (default /bin/sh), independent of a
// PBXShellScriptBuildPhase's own shellPath (see shellScriptPhase) — pin
// it to bash too. `>` can't appear unescaped inside either attribute
// value, so it reliably closes the ActionContent open tag. Search the
// whole open tag (not just after scriptText) and allow any spacing
// around `=`, since attribute order and formatting aren't guaranteed.
const contentOpenIdx = xmlWithScript.lastIndexOf(
'<ActionContent',
titleIdx,
);
const contentCloseIdx = xmlWithScript.indexOf('>', stIdx);
const openTag = xmlWithScript.slice(contentOpenIdx, contentCloseIdx);
const stiMatch = openTag.match(/shellToInvoke\s*=\s*"/);
if (stiMatch != null) {
const stiValueStart =
contentOpenIdx + stiMatch.index + stiMatch[0].length;
const stiValueEnd = xmlWithScript.indexOf('"', stiValueStart);
xmlWithScript =
xmlWithScript.slice(0, stiValueStart) +
'/bin/bash' +
xmlWithScript.slice(stiValueEnd);
} else {
xmlWithScript =
xmlWithScript.slice(0, contentCloseIdx) +
'\n shellToInvoke = "/bin/bash"' +
xmlWithScript.slice(contentCloseIdx);
}
return xmlWithScript;
}
const refMatch = xml.match(
new RegExp(
`<BuildableReference\\b[^>]*BlueprintIdentifier = "${targetUuid}"[^>]*>`,
),
);
const attr = (name /*: string */) => {
const m =
refMatch != null
? refMatch[0].match(new RegExp(`${name} = "([^"]*)"`))
: null;
return m != null ? m[1] : '';
};
const cleanRef =
`<BuildableReference\n` +
` BuildableIdentifier = "primary"\n` +
` BlueprintIdentifier = "${targetUuid}"\n` +
` BuildableName = "${attr('BuildableName')}"\n` +
` BlueprintName = "${attr('BlueprintName')}"\n` +
` ReferencedContainer = "${attr('ReferencedContainer')}">\n` +
` </BuildableReference>`;
const executionAction =
` <ExecutionAction\n` +
` ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">\n` +
` <ActionContent\n` +
` title = "Sync SPM Autolinking"\n` +
` scriptText = "${escapeXmlAttribute(syncScript)}"\n` +
` shellToInvoke = "/bin/bash">\n` +
` <EnvironmentBuildable>\n` +
` ${cleanRef}\n` +
` </EnvironmentBuildable>\n` +
` </ActionContent>\n` +
` </ExecutionAction>`;
if (/<PreActions>/.test(xml)) {
return xml.replace(
'</PreActions>',
`${executionAction}\n </PreActions>`,
);
}
const openEnd = xml.indexOf('>', xml.indexOf('<BuildAction'));
if (openEnd < 0) {
return xml; // no BuildAction — leave the scheme untouched
}
const block = `\n <PreActions>\n${executionAction}\n </PreActions>`;
return xml.slice(0, openEnd + 1) + block + xml.slice(openEnd + 1);
}
/**
* Ensure the app target's shared scheme runs the sync pre-action before SPM
* resolution. Updates the scheme that builds the target if one exists,
* otherwise creates a fresh shared scheme. Returns 'updated' | 'created' |
* 'unchanged'.
*/
function injectOrCreateScheme(
xcodeprojDir /*: string */,
opts /*: {appName: string, targetUuid: string, projName: string, syncScript: string} */,
) /*: {status: 'updated' | 'unchanged' | 'created', file: string} */ {
const schemesDir = path.join(xcodeprojDir, 'xcshareddata', 'xcschemes');
let schemeFiles /*: Array<string> */ = [];
try {
schemeFiles = fs
.readdirSync(schemesDir)
.filter(f => f.endsWith('.xcscheme'));
} catch {
/* no shared schemes dir yet */
}
for (const f of schemeFiles) {
const p = path.join(schemesDir, f);
const xml = fs.readFileSync(p, 'utf8');
if (xml.includes(`BlueprintIdentifier = "${opts.targetUuid}"`)) {
const updated = addPreActionToScheme(
xml,
opts.targetUuid,
opts.syncScript,
);
return {
status: writeIfChanged(p, updated) ? 'updated' : 'unchanged',
file: f,
};
}
}
const file = `${opts.appName}.xcscheme`;
const xml = generateXcscheme(
opts.appName,
opts.targetUuid,
opts.projName,
opts.syncScript,
);
writeIfChanged(path.join(schemesDir, file), xml);
return {status: 'created', file};
}
/**
* Strip the empty `Pods` group `pod deintegrate` leaves in the navigator.
* Called by `add --deintegrate` after deintegration so the converted project is
* visually clean. No-op when absent or when the group still has children.
*/
function cleanupLeftoverPodsGroup(xcodeprojPath /*: string */) /*: boolean */ {
const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
if (!fs.existsSync(pbxprojPath)) {
return false;
}
const original = fs.readFileSync(pbxprojPath, 'utf8');
const cleaned = removeEmptyPodsGroup(original);
return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false;
}
/**
* Strip the dangling `JavaScriptCore.framework` file reference the community
* template has carried since RN 0.60 (navigator-only, meaningless under
* Hermes) — see `removeDanglingJavaScriptCoreRef` for the full rationale and
* the safety gate that leaves a still-linked reference untouched. No-op when
* absent or when the pbxproj is missing.
*/
function cleanupDanglingJavaScriptCoreRef(
xcodeprojPath /*: string */,
) /*: boolean */ {
const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
if (!fs.existsSync(pbxprojPath)) {
return false;
}
const original = fs.readFileSync(pbxprojPath, 'utf8');
const cleaned = removeDanglingJavaScriptCoreRef(original);
return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false;
}
/**
* Normalize one plugin generated-source path into the fields a PBXFileReference
* needs. Stores an SRCROOT-relative path (`sourceTree = SOURCE_ROOT`) when the
* source lives under the app root — the typical case (build/generated/…) — and
* an absolute path (`sourceTree = "<absolute>"`) otherwise. Returns null (with a
* loud log) for an extension the pbxproj can't compile.
*/
function normalizeGeneratedSource(
appRoot /*: string */,
srcPath /*: string */,
) /*: ?GeneratedSource */ {
const ext = path.extname(srcPath).toLowerCase();
const fileType = GENERATED_SOURCE_FILE_TYPES[ext];
if (fileType == null) {
log(
`warning: unsupported generated-source extension "${ext}" for ` +
`${srcPath}; skipping (SPM plugin sources must be .swift/.m/.mm).`,
);
return null;
}
const abs = path.isAbsolute(srcPath)
? srcPath
: path.resolve(appRoot, srcPath);
const rel = path.relative(appRoot, abs);
const underAppRoot =
rel !== '' &&
rel !== '..' &&
!rel.startsWith('..' + path.sep) &&
!path.isAbsolute(rel);
return {
path: underAppRoot ? rel : abs,
name: path.basename(abs),
sourceTree: underAppRoot ? 'SOURCE_ROOT' : '"<absolute>"',
fileType,
};
}
/**
* Read + normalize the plugin generated-sources manifest at
* `<appRoot>/build/generated/autolinking/.spm-plugin-generated-sources.json`.
* Absent, empty, or malformed → `[]` (the feature stays inert for non-plugin
* apps and never breaks injection). The file need not exist yet at inject time:
* the build-time sync regenerates it before compile, and a PBXFileReference to a
* not-yet-created path is valid.
*/
function readGeneratedSourcesManifest(
appRoot /*: string */,
) /*: Array<GeneratedSource> */ {
const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST);
let raw /*: string */ = '';
try {
raw = fs.readFileSync(manifestPath, 'utf8');
} catch {
return [];
}
let entries /*: unknown */ = null;
try {
entries = JSON.parse(raw);
} catch {
log(
`warning: could not parse ${SPM_GENERATED_SOURCES_MANIFEST}; ` +
'skipping generated sources.',
);
return [];
}
if (!Array.isArray(entries)) {
return [];
}
const out /*: Array<GeneratedSource> */ = [];
for (const entry of entries) {
if (
entry == null ||
typeof entry !== 'object' ||
typeof entry.path !== 'string'
) {
continue;
}
const normalized = normalizeGeneratedSource(appRoot, entry.path);
// Dedupe by normalized path — a duplicate manifest entry would otherwise
// double-insert identical-UUID pbxproj objects.
if (normalized != null && !out.some(s => s.path === normalized.path)) {
out.push(normalized);
}
}
return out;
}
const nonEmptyStrings = (value /*: unknown */) /*: ?Array<string> */ =>
Array.isArray(value)
? value.filter(p => typeof p === 'string' && p.length > 0)
: null;
/**
* Read the plugin script-phases manifest at
* `<appRoot>/build/generated/autolinking/.spm-plugin-script-phases.json`.
* Absent, unparseable, or malformed → `[]`. Lenient by design even though the
* plugin contract validates these entries fatally at invoke time: the sidecar
* legitimately does not exist yet on a first `spm add`, and a stale or
* hand-edited file must not break injection. This reader is the ONLY gate on
* such a file, so it applies the same id/name rules (from spm-utils) that
* invokePlugins enforces fatally.
*/
function readScriptPhasesManifest(
appRoot /*: string */,
) /*: Array<PluginScriptPhase> */ {
const manifestPath = path.join(appRoot, SPM_SCRIPT_PHASES_MANIFEST);
let raw /*: string */ = '';
try {
raw = fs.readFileSync(manifestPath, 'utf8');
} catch {
return [];
}
let entries /*: unknown */ = null;
try {
entries = JSON.parse(raw);
} catch {
log(
`warning: could not parse ${SPM_SCRIPT_PHASES_MANIFEST}; ` +
'skipping script phases.',
);
return [];
}
if (!Array.isArray(entries)) {
return [];
}
const out /*: Array<PluginScriptPhase> */ = [];
for (const entry of entries) {
if (
entry == null ||
typeof entry !== 'object' ||
!isValidScriptPhaseId(entry.id) ||
!isValidScriptPhaseName(entry.name) ||
typeof entry.script !== 'string' ||
entry.script.length === 0 ||
// An unknown position is a malformed entry, not something to coerce: it
// would silently run somewhere the plugin didn't ask for.
(entry.position != null &&
entry.position !== 'beforeCompile' &&
entry.position !== 'end') ||
// Dedupe by id — the id seeds the phase's UUID, so a duplicate would
// insert two objects with identical UUIDs.
out.some(phase => phase.id === entry.id)
) {
continue;
}
const phase /*: PluginScriptPhase */ = {
id: entry.id,
name: entry.name,
script: entry.script,
position: entry.position ?? 'end',
};
const inputPaths = nonEmptyStrings(entry.inputPaths);
const outputPaths = nonEmptyStrings(entry.outputPaths);
if (inputPaths != null) {
phase.inputPaths = inputPaths;
}
if (outputPaths != null) {
phase.outputPaths = outputPaths;
}
if (typeof entry.alwaysOutOfDate === 'boolean') {
phase.alwaysOutOfDate = entry.alwaysOutOfDate;
}
out.push(phase);
}
return out;
}
/**
* Read the `.spm-injected.json` marker of a previously-injected project, or
* null when absent/unreadable. Used to reconcile generated sources on `update`
* and to read back a pinned `artifactsVersionOverride` (see below).
*/
function readMarker(
xcodeprojPath /*: string */,
) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array<string>}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array<string>, buildSettingChanges?: Array<BuildSettingChange>, xcconfig?: XcconfigRecord, createdArrayFields?: Array<CreatedArrayField>, scheme?: {file?: ?string, created?: ?boolean}, ...} */ {
const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
try {
// $FlowFixMe[incompatible-return] JSON.parse returns any
return JSON.parse(fs.readFileSync(markerPath, 'utf8'));
} catch {
return null;
}
}
// Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json`
// marker (the user-owned project SPM packages were injected into in place),
// or null when none has been injected yet. Pure fs reads — safe for the
// marker readers below, and for callers that only locate the project (setup-
// apple-spm.js's action defaulting and `deinit`), to call without exercising
// any pbxproj-editing machinery.
function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
try {
// $FlowFixMe[incompatible-type] Dirent typing
entries = fs.readdirSync(appRoot, {withFileTypes: true});
} catch {
return null;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs
const name /*: string */ = entry.name;
if (!name.endsWith('.xcodeproj')) continue;
if (fs.existsSync(path.join(appRoot, name, SPM_INJECTED_MARKER))) {
return path.join(appRoot, name);
}
}
return null;
}
class PluginFrameworkMismatchError extends Error {
/*:: problems: Array<string>; */
constructor(problems /*: Array<string> */) {
super(
'The precompiled frameworks that autolinking plugins provide differ ' +
'from the frameworks that the Xcode project links.',
);
this.name = 'PluginFrameworkMismatchError';
this.problems = problems;
}
}
/**
* The frameworks the project links: the setting prefix of every one, and the
* names of the plugin ones. A marker that records them also pairs each plugin
* framework's id with its name. For an older marker they come from the embed
* phase, which cannot pair them: null without one, and null names when the
* frameworks manifest that tells plugin names from built-in ones is missing.
*/
function readLinkedFrameworks(
appRoot /*: string */,
xcodeprojPath /*: string */,
) /*: ?{
settingPrefixes: Set<string>,
pluginNames: ?Set<string>,
pluginFrameworks?: Array<{id: string, frameworkName: string}>,
} */ {
const builtinIds = new Set(BUILTIN_FRAMEWORKS.map(({id}) => id));
const recorded = readMarker(xcodeprojPath)?.linkedFrameworks;
if (recorded != null) {
const pluginFrameworks = recorded.filter(
framework => !builtinIds.has(framework.id),
);
return {
settingPrefixes: new Set(
recorded.map(framework => frameworkSettingPrefix(framework.id)),
),
pluginNames: new Set(
pluginFrameworks.map(framework => framework.frameworkName),
),
pluginFrameworks,
};
}
// Markers written before `linkedFrameworks` existed: read the embed phase.
const text = fs.readFileSync(
path.join(xcodeprojPath, 'project.pbxproj'),
'utf8',
);
const rootUuid = findProjectObject(text)?.uuid;
if (rootUuid == null) {
return null;
}
const embedPhase = findObjectByUuid(
text,
namespacedUUID(
rootUuid,
'PBXShellScriptBuildPhase',
'EmbedFlavoredFrameworks',
),
);
if (embedPhase == null) {
return null;
}
const inputs = findField(text, embedPhase, 'inputPaths')?.value ?? '';
const outputs = findField(text, embedPhase, 'outputPaths')?.value ?? '';
const settingPrefixes = new Set(
Array.from(inputs.matchAll(/\$\((RN_SPM_\w+)_FRAMEWORK\)/g), m => m[1]),
);
// Built-in framework names come from the staged manifest. Without it, only
// setting prefixes are compared: a missing manifest is not a plugin mismatch.
if (
!fs.existsSync(
path.join(appRoot, 'build', 'xcframeworks', FLAVORED_FRAMEWORKS_MANIFEST),
)
) {
return {settingPrefixes, pluginNames: null};
}
const builtinNames = new Set(
readFlavoredFrameworksManifest(appRoot)
.frameworks.filter(framework => builtinIds.has(framework.id))
.map(framework => framework.frameworkName),
);
const pluginNames = new Set(
Array.from(outputs.matchAll(/([^/"\n]+)\.framework\b/g), m => m[1]).filter(
name => !builtinNames.has(name),
),
);
return {settingPrefixes, pluginNames};
}
/**
* Only `spm add` / `spm update` change what the project links, so the
* build-time sync reports a plugin framework mismatch instead of fixing it.
* The committed project is the team's contract: every machine precompiles the
* frameworks it links.
*/
function assertPluginFrameworksLinked(
appRoot /*: string */,
env /*: {readonly [string]: ?string} */ = process.env,
) /*: void */ {
const builtProjectPath = env.PROJECT_FILE_PATH;
const xcodeprojPath =
builtProjectPath != null &&
builtProjectPath.endsWith('.xcodeproj') &&
fs.existsSync(path.join(builtProjectPath, SPM_INJECTED_MARKER))
? builtProjectPath
: findInjectedXcodeproj(appRoot);
if (xcodeprojPath == null) {
return;
}
const linked = readLinkedFrameworks(appRoot, xcodeprojPath);
if (linked == null) {
return;
}
const pluginFrameworks = readPluginFrameworks(appRoot);
const pairedNames = new Set(
pluginFrameworks.map(framework => framework.frameworkName),
);
const pairKey = (
{id, frameworkName} /*: {id: string, frameworkName: string, ...} */,
) => JSON.stringify([id, frameworkName]);
const pairedPairs = new Set(pluginFrameworks.map(pairKey));
const problems /*: Array<string> */ = [];
for (const {id, frameworkName} of pluginFrameworks) {
// A name linked under another id is reported once, from the linked side.
const notLinked =
linked.pluginFrameworks != null
? linked.pluginNames?.has(frameworkName) === false
: !linked.settingPrefixes.has(frameworkSettingPrefix(id)) ||
linked.pluginNames?.has(frameworkName) === false;
if (notLinked) {
problems.push(
`error: ${frameworkName} (${id}) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run \`npx react-native spm update\` to add ${frameworkName} to the committed project for the whole team.`,
);
}
}
if (linked.pluginNames == null) {
console.warn(
'warning: Skipped the check for frameworks that the Xcode project links but no plugin provides. The project does not record them in .spm-injected.json, and build/xcframeworks/flavored-frameworks.json is missing. Run `npx react-native spm update` to record them.',
);
}
for (const frameworkName of linked.pluginNames ?? []) {
if (!pairedNames.has(frameworkName)) {
// Precompiling changes no input the build checks, so the recorded
// error stays until sync runs again.
problems.push(
`error: The Xcode project links ${frameworkName}.framework, but no autolinking plugin provides it on this machine. Running \`npx react-native spm update\` removes ${frameworkName} from the committed project for the whole team. Instead, precompile ${frameworkName}, then run \`npx react-native spm sync\`.`,
);
}
}
for (const framework of linked.pluginFrameworks ?? []) {
const {id, frameworkName} = framework;
if (
pairedNames.has(frameworkName) &&
!pairedPairs.has(pairKey(framework))
) {
problems.push(
`error: The Xcode project links ${frameworkName}.framework (${id}), but the autolinking plugin that provides ${frameworkName} gives it a different id. Run \`npx react-native spm update\` to relink ${frameworkName} in the committed project for the whole team.`,
);
}
}
if (problems.length > 0) {
problems.forEach(problem => console.error(problem));
throw new PluginFrameworkMismatchError(problems);
}
}
/**
* Read the `artifactsVersionOverride` a previous `spm add --version` / `spm
* update --version` pinned into the injected xcodeproj's `.spm-injected.json`
* marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
* below), or null when no project is injected yet, no override is pinned, or
* the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's
* determineVersion prefers the pinned version over the one derived from
* node_modules/react-native/package.json, so a later flagless `add`/`update`
* (and `download`) stays on the SAME artifact slot the explicit `--version`
* selected.
*/
function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
const xcodeprojPath = findInjectedXcodeproj(appRoot);
if (xcodeprojPath == null) {
return null;
}
const override = readMarker(xcodeprojPath)?.artifactsVersionOverride;
return typeof override === 'string' && override.length > 0 ? override : null;
}
/**
* Read the autolinking config command a previous `spm add`/`update` pinned into
* the injected xcodeproj's `.spm-injected.json` marker, or null when nothing
* usable is pinned. Pure fs reads, like readArtifactsVersionOverride above, but
* this one IS wired: setup-apple-spm.js's resolveExplicitConfigCommand reads it
* on add/update/scaffold and on the build-time `sync`. Re-validated through the
* same parseConfigCommandJson the flag goes through, and never throws, so a
* hand-edited marker degrades to the env-var/default command instead of
* injecting a bogus argv into the build.
*/
function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array<string> */ {
const xcodeprojPath = findInjectedXcodeproj(appRoot);
if (xcodeprojPath == null) {
return null;
}
const pinned = readMarker(xcodeprojPath)?.configCommand;
if (pinned == null) {
return null;
}
try {
return parseConfigCommandJson(JSON.stringify(pinned), SPM_INJECTED_MARKER);
} catch {
return null;
}
}
/**
* Union the array fields RN has created across syncs, deduped by container+key.
*
* THE CANONICAL STATEMENT of why anything RN created is recorded stickily (the
* marker's `scheme.created` carries it forward for the same reason): a re-sync
* re-injects from a baseline with only the BUILD SETTINGS reversed, so it finds
* whatever the first run created already there and reports creating nothing. Once
* RN has created something, that fact has to be carried forward, or the new
* marker forgets it and `deinit` leaves an empty `packageReferences` /
* `packageProductDependencies` behind and the generated scheme on disk.
*
* The record licenses removal; it does not order it. `deinit` still checks that
* what it is about to remove is RN's (see its step 1 and isGeneratedScheme), so
* carrying forward a field the user has since taken over — or deleted by hand —
* is safe.
*/
function mergeCreatedArrayFields(
previous /*: ReadonlyArray<CreatedArrayField> */,
current /*: ReadonlyArray<CreatedArrayField> */,
) /*: Array<CreatedArrayField> */ {
const merged = [...previous];
for (const field of current) {
if (
!merged.some(
seen => seen.container === field.container && seen.key === field.key,
)
) {
merged.push(field);
}
}
return merged;
}
// The react-native dir the app reaches through `node_modules/react-native`, in
// the nearest ancestor of `appRoot` where that link resolves to
// `reactNativeRoot`. Config commands report the realpath, which under pnpm is a
// version- and hash-named store dir that must not end up in committed files.
function linkedReactNativeRoot(
appRoot /*: string */,
reactNativeRoot /*: string */,
) /*: string */ {
try {
const target = fs.realpathSync(reactNativeRoot);
let dir = path.resolve(appRoot);
while (true) {
const candidate = path.join(dir, 'node_modules', 'react-native');
if (fs.existsSync(candidate) && fs.realpathSync(candidate) === target) {
return candidate;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
} catch {}
return reactNativeRoot;
}
/**
* Add SPM packages to a user's EXISTING xcodeproj in place. Returns
* {status: 'injected', target} on success, or {status: 'refused', reason}
* when the project can't be safely edited (caller surfaces it; fail-loud).
*/
function injectSpmIntoExistingXcodeproj(
opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array<string>} */,
) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ {
const {appRoot, reactNativeRoot, xcodeprojPath} = opts;
const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
if (!fs.existsSync(pbxprojPath)) {
return {
status: 'refused',
reason: `no project.pbxproj at ${xcodeprojPath}`,
};
}
const original = fs.readFileSync(pbxprojPath, 'utf8');
const prevMarker = readMarker(xcodeprojPath);
// A project injected before the React build settings moved to the generated
// xcconfig carries them in project.pbxproj. Reversing that record first moves
// it over, and has to precede planning: a setting the user had without
// $(inherited) only shows again once the promotion that added it is undone.
const withoutRecordedSettings = removeRecordedBuildSettings(
original,
prevMarker?.buildSettingChanges ?? [],
);
const plan = planInjection(withoutRecordedSettings, {
appName: opts.appName,
appRoot,
srcRoot: path.dirname(xcodeprojPath),
});
if (!plan.ok) {
return {status: 'refused', reason: plan.reason};
}
const reactNativeDir = linkedReactNativeRoot(appRoot, reactNativeRoot);
const reactNativePaths = {
fromAppRoot: path.relative(appRoot, reactNativeDir),
fromSrcRoot: path.relative(path.dirname(xcodeprojPath), reactNativeDir),
};
const remote = remotePackageConfig(appRoot);
const generatedSources = readGeneratedSourcesManifest(appRoot);
const scriptPhases = readScriptPhasesManifest(appRoot);
const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks;
// Reconcile generated sources injected on a PRIOR run that are no longer in
// the manifest (a plugin's entry was dropped, or the plugin was removed).
// Diff the marker's `generatedSources` map against the current manifest and
// delete only the stale UUIDs — the additive injection below re-emits (and
// idempotently skips) everything that remains, so an unchanged run stays
// byte-identical. deinit needs none of this: the removed objects live in
// `injectedUuids`.
const prevGeneratedSources /*: {[string]: Array<string>} */ =
prevMarker?.generatedSources ?? {};
const currentPaths = new Set(generatedSources.map(s => s.path));
const staleUuids /*: Array<string> */ = [];
for (const p of Object.keys(prevGeneratedSources)) {
if (!currentPaths.has(p)) {
staleUuids.push(...prevGeneratedSources[p]);
}
}
// When the last generated source is gone, retire the now-empty group too.
if (
generatedSources.length === 0 &&
Object.keys(prevGeneratedSources).length > 0
) {
staleUuids.push(
namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID),
);
}
// Same reconciliation for script phases, keyed on the plugin-supplied id.
const prevScriptPhases /*: {[string]: string} */ =
prevMarker?.scriptPhases ?? {};
const currentPhaseIds = new Set(scriptPhases.map(p => p.id));
for (const id of Object.keys(prevScriptPhases)) {
if (!currentPhaseIds.has(id)) {
staleUuids.push(prevScriptPhases[id]);
}
}
let base = withoutRecordedSettings;
if (staleUuids.length > 0) {
base = removeArrayMembersByUuid(base, staleUuids);
for (const u of staleUuids) {
base = removeObjectByUuid(base, u);
}
}
const {
text,
injectedUuids,
createdArrayFields,
xcconfig,
generatedFiles,
generatedSourceUuids,
scriptPhaseUuids,
} = injectSpmIntoPbxproj(
base,
{
rootUuid: plan.rootUuid,
targetUuid: plan.target.uuid,
configUuids: plan.configUuids,
includedByConfigUuids: plan.includedByConfigUuids,
frameworksPhaseUuid: plan.frameworksPhaseUuid,
sourcesPhaseUuid: plan.sourcesPhaseUuid,
},
reactNativePaths,
remote,
generatedSources,
flavoredFrameworks,
scriptPhases,
);
// Before the project, so an open Xcode that reloads it finds them in place.
for (const [relPath, content] of Object.entries(generatedFiles)) {
writeIfChanged(path.join(appRoot, relPath), content);
}
const changed = writeIfChanged(pbxprojPath, text);
log(
changed
? `Injected SPM packages into ${path.relative(appRoot, pbxprojPath)}`
: `${path.relative(appRoot, pbxprojPath)} already up to date`,
);
const projName = path.basename(xcodeprojPath, '.xcodeproj');
const schemeResult = injectOrCreateScheme(xcodeprojPath, {
appName: plan.target.name,
targetUuid: plan.target.uuid,
projName,
// The scheme pre-action is SYNC-ONLY (no flavor swap). A pre-action swap
// could win its race and mask a mismatch from the in-target detector.
syncScript: buildSchemePreActionScript(reactNativePaths.fromAppRoot),
});
log(`Scheme sync pre-action: ${schemeResult.status}`);
// The RN version this app's xcframework artifact-cache slot should be
// pinned to, when `add`/`update` was given an EXPLICIT `--version` — SETS
// the pin. Omitting `--version` (opts.artifactsVersionOverride is null)
// PRESERVES whatever was recorded on a prior run, since it's an
// intentional pin, not something to silently re-derive from
// node_modules/react-native/package.json. There is no "clear" verb yet;
// `deinit` (removeSpmInjection) drops the whole marker, including this
// field. Read back by readArtifactsVersionOverride (above) so a later
// flagless `add`/`update`/`download` resolves to the SAME slot, even on a
// version-mismatched setup.
const artifactsVersionOverride =
opts.artifactsVersionOverride ??
prevMarker?.artifactsVersionOverride ??
null;
// Same set-or-preserve contract as the version pin above, for the autolinking
// config command `add`/`update` resolved (`--config-command` or
// RCT_SPM_AUTOLINKING_CONFIG_COMMAND) — the build-time sync sees neither the
// flag nor the developer's shell environment, so without the pin it
// re-derives autolinking.json with the default @react-native-community/cli
// command and breaks apps that replace it. Read back by
// readPinnedConfigCommand (above). No "clear" verb yet either; `deinit` drops
// the whole marker, this field with it.
const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null;
// Marker: idempotency signal + the exact, reversible record of every edit so
// `deinit` (removeSpmInjection) can undo precisely what was added.
writeIfChanged(
path.join(xcodeprojPath, SPM_INJECTED_MARKER),
JSON.stringify(
{
rootUuid: plan.rootUuid,
target: plan.target.name,
targetUuid: plan.target.uuid,
injectedUuids: Array.from(new Set(injectedUuids)).sort(),
createdArrayFields: mergeCreatedArrayFields(
prevMarker?.createdArrayFields ?? [],
createdArrayFields,
),
xcconfig: {
...xcconfig,
// Sticky: the next sync no longer sees the value this one removed.
removedPodsRootReactNativePath:
xcconfig.removedPodsRootReactNativePath ||
prevMarker?.xcconfig?.removedPodsRootReactNativePath === true,
},
// Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next
// `update` to reconcile away entries that left the manifest.
generatedSources: generatedSourceUuids,
// Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the
// same way.
scriptPhases: scriptPhaseUuids,
// What the embed phase links. The build-time sync compares it with
// the frameworks plugins provide, and re-runs when it changes.
linkedFrameworks: flavoredFrameworks.map(({id, frameworkName}) => ({
id,
frameworkName,
})),
artifactsVersionOverride,
configCommand,
scheme: {
file: schemeResult.file,
// Sticky — see mergeCreatedArrayFields for why a later sync cannot
// observe this for itself.
created:
schemeResult.status === 'created' ||
prevMarker?.scheme?.created === true,
},
},
null,
2,
) + '\n',
);
ensureStubPackages(appRoot);
return {status: 'injected', target: plan.target.name};
}
// The scheme with the sync pre-action's scriptText emptied, so the compare
// ignores how the script was encoded; null when there is no pre-action.
function withoutPreActionScript(xml /*: string */) /*: ?string */ {
const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"');
if (titleIdx < 0) {
return null;
}
const marker = 'scriptText = "';
const start = xml.indexOf(marker, titleIdx);
if (start < 0) {
return null;
}
const valueStart = start + marker.length;
// escapeXmlAttribute maps a literal `"` to `"`, so the next `"` is always
// the closing delimiter.
const valueEnd = xml.indexOf('"', valueStart);
return valueEnd < 0 ? null : xml.slice(0, valueStart) + xml.slice(valueEnd);
}
/**
* Whether `xml` is still, byte for byte, the scheme RN generates for this target
* — everything but the pre-action's script, which RN rewrites in place on every
* sync and which varies with the app's react-native path, so it cannot be part of
* an ownership test.
*
* `deinit` deletes a scheme the marker says RN created only while this holds. A
* user may replace a generated scheme with one of their own under the same name
* (same target, so `injectOrCreateScheme` finds and updates it, and the created
* record stays), and destroying that is unrecoverable where leaving a scheme
* behind is not — so the harmless way of being wrong wins: a generated scheme the
* user has since edited leaks, minus its pre-action.
*/
function isGeneratedScheme(
xml /*: string */,
appName /*: string */,
targetUuid /*: string */,
projName /*: string */,
) /*: boolean */ {
return (
withoutPreActionScript(xml) ===
generateXcscheme(appName, targetUuid, projName, '')
);
}
/**
* Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added
* to a scheme, and drop the `<PreActions>` wrapper if it is left empty (the
* byte-identical inverse for the common case where injection created it).
*/
function removePreActionFromScheme(xml /*: string */) /*: string */ {
const withoutAction = xml.replace(
/[ \t]*<ExecutionAction\b(?:(?!<\/ExecutionAction>)[\s\S])*?title = "Sync SPM Autolinking"(?:(?!<\/ExecutionAction>)[\s\S])*?<\/ExecutionAction>\n?/,
'',
);
return withoutAction.replace(/\n[ \t]*<PreActions>\s*<\/PreActions>/, '');
}
function removeRecordedBuildSettings(
input /*: string */,
changes /*: ReadonlyArray<BuildSettingChange> */,
) /*: string */ {
let text = input;
for (const change of changes) {
const dict = () => {
const config = findObjectByUuid(text, change.configUuid);
if (config == null) {
return null;
}
const buildSettings = findField(text, config, 'buildSettings');
if (buildSettings == null) {
return null;
}
return {
uuid: change.configUuid,
bodyOpen: buildSettings.valueStart,
bodyClose: buildSettings.tokenEnd - 1,
};
};
for (const key of Object.keys(change.appendedArrayValues ?? {})) {
const current = dict();
if (current != null) {
text = removeArrayStringValues(
text,
current,
key,
change.appendedArrayValues[key],
);
}
}
const promotedArrayScalars /*: {[string]: string} */ =
change.promotedArrayScalars ?? {};
for (const key of Object.keys(promotedArrayScalars)) {
const current = dict();
const originalValue = promotedArrayScalars[key];
// A field that is gone was deleted by the user after injection; restoring
// it would resurrect it, at the top of the dict, matching neither state.
if (
current != null &&
typeof originalValue === 'string' &&
findField(text, current, key) != null
) {
// Rewriting the whole value is what makes the promotion reversible at
// all — its members and its `"$(inherited)"` seed are indistinguishable
// from the user's own once folded together. The tradeoff: members the
// user hand-added to the promoted array afterwards are discarded.
text = setScalarField(text, current, key, originalValue);
}
}
for (const key of change.createdArrayKeys ?? []) {
const current = dict();
if (current != null) {
text = removeField(text, current, key);
}
}
for (const key of change.createdScalars ?? []) {
const current = dict();
if (current != null) {
text = removeField(text, current, key);
}
}
const replacedScalars /*: {[string]: string} */ =
change.replacedScalars ?? {};
for (const key of Object.keys(replacedScalars)) {
const current = dict();
if (current != null) {
text = removeField(text, current, key);
const replacement = dict();
if (replacement != null) {
const originalValue = replacedScalars[key];
if (typeof originalValue === 'string') {
text = ensureScalarField(text, replacement, key, originalValue);
}
}
}
}
}
return text;
}
/**
* The configurations, by name, that may #include the generated xcconfig once
* deinit has un-based its own: those based on any other xcconfig, and those
* recorded as including it.
*/
function configsThatMayInclude(
text /*: string */,
targetUuid /*: string */,
xcconfig /*: XcconfigRecord */,
) /*: Array<string> */ {
const target = findObjectByUuid(text, targetUuid);
const rebased = (
target != null ? targetBuildConfigUuids(text, target) : []
).filter(uuid => {
const config = findObjectByUuid(text, uuid);
const base =
config != null
? findField(text, config, 'baseConfigurationReference')
: null;
return base != null && !base.value.includes(xcconfig.fileRefUuid);
});
return [
...new Set([...rebased, ...(xcconfig.includedByConfigUuids ?? [])]),
].map(uuid =>
findObjectByUuid(text, uuid) != null
? buildConfigurationName(text, uuid)
: uuid,
);
}
/**
* The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the
* `.spm-injected.json` marker's precise record of every edit, remove only what
* injection added — leaving any other (user) edits made afterwards intact. No
* `git checkout`, no prompt. Returns {status:'absent'} when the project was
* never injected.
*/
function removeSpmInjection(
opts /*: {appRoot: string, xcodeprojPath: string} */,
) /*: {status: 'removed', target: string} | {status: 'absent'} */ {
const {appRoot, xcodeprojPath} = opts;
const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
if (!fs.existsSync(markerPath)) {
return {status: 'absent'};
}
// $FlowFixMe[incompatible-type] JSON.parse returns any
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
let text = fs.readFileSync(pbxprojPath, 'utf8');
const injectedUuids /*: Array<string> */ = marker.injectedUuids ?? [];
// 1. Drop our array members, then the array fields we created (now empty),
// then the injected object definitions.
text = removeArrayMembersByUuid(text, injectedUuids);
for (const f of marker.createdArrayFields ?? []) {
const obj =
f.container === 'project'
? findProjectObject(text)
: findObjectByUuid(text, marker.targetUuid);
if (obj == null) {
continue;
}
// The record says the field did not exist before RN created it, which is
// necessary but not sufficient: anything still in it after our own members
// are gone is the user's (their own SPM package, added to the same field),
// and dropping the field would orphan it.
const field = findField(text, obj, f.key);
if (field != null && /^\(\s*\)$/.test(field.value)) {
text = removeField(text, obj, f.key);
}
}
for (const uuid of injectedUuids) {
text = removeObjectByUuid(text, uuid);
}
// Drop any section that injection created and we just emptied (e.g.
// XCLocalSwiftPackageReference) — a well-formed pbxproj never carries an
// empty `/* Begin X */ /* End X */` section, so this lands byte-identical.
text = text.replace(
/\/\* Begin (\w+) section \*\/\n\/\* End \1 section \*\/\n\n/g,
'',
);
// 2. Un-base the configurations still based on the generated xcconfig, and
// delete it only then, and only while no configuration is based on an
// xcconfig of the user's own — that file may #include it, as the ones
// recorded in `includedByConfigUuids` do. A ${PODS_ROOT}-anchored
// REACT_NATIVE_PATH injection removed is deliberately not put back: it
// resolves empty without CocoaPods.
const xcconfig /*: ?XcconfigRecord */ = marker.xcconfig;
let ownsXcconfig = false;
if (xcconfig != null) {
for (const configUuid of xcconfig.configUuids) {
const config = findObjectByUuid(text, configUuid);
if (
config != null &&
findField(text, config, 'baseConfigurationReference')?.value.includes(
xcconfig.fileRefUuid,
) === true
) {
text = removeField(text, config, 'baseConfigurationReference');
ownsXcconfig = true;
}
}
}
const includingConfigs =
xcconfig != null
? configsThatMayInclude(text, marker.targetUuid, xcconfig)
: [];
if (includingConfigs.length > 0) {
log(
`Kept ${GENERATED_XCCONFIG_PATH}: an xcconfig of your own may still ` +
`#include it (configurations: ${includingConfigs.join(', ')})`,
);
}
// Settings a project injected before the generated xcconfig still carries
// in project.pbxproj (only what we added).
text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []);
writeIfChanged(pbxprojPath, text);
log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`);
for (const relPath of [
...(ownsXcconfig && includingConfigs.length === 0
? [GENERATED_XCCONFIG_PATH]
: []),
SYNC_AUTOLINKING_SCRIPT_PATH,
EMBED_FRAMEWORKS_SCRIPT_PATH,
]) {
fs.rmSync(path.join(appRoot, relPath), {force: true});
}
// 3. Scheme: delete it if injection created it AND still owns its contents,
// else strip the pre-action and leave the file (see isGeneratedScheme).
const scheme = marker.scheme;
if (scheme != null && scheme.file != null) {
const schemePath = path.join(
xcodeprojPath,
'xcshareddata',
'xcschemes',
scheme.file,
);
if (fs.existsSync(schemePath)) {
const xml = fs.readFileSync(schemePath, 'utf8');
const ours =
scheme.created === true &&
isGeneratedScheme(
xml,
marker.target,
marker.targetUuid,
path.basename(xcodeprojPath, '.xcodeproj'),
);
if (ours) {
fs.rmSync(schemePath, {force: true});
} else {
writeIfChanged(schemePath, removePreActionFromScheme(xml));
}
}
}
// 4. Drop the marker — the project is no longer SPM-injected.
fs.rmSync(markerPath, {force: true});
return {status: 'removed', target: marker.target};
}
module.exports = {
generateXcscheme,
buildSyncAutolinkingScript,
buildSchemePreActionScript,
buildEmbedFrameworksScript,
flavorForBuildConfiguration,
targetBuildConfigUuids,
frameworkConditionalSettings,
generateReactNativeXcconfig,
ensureStubPackages,
buildSpmDependencyGraph,
spmGraphToEntries,
buildPhaseOrder,
planInjection,
injectSpmIntoPbxproj,
injectSpmIntoExistingXcodeproj,
removeSpmInjection,
cleanupLeftoverPodsGroup,
cleanupDanglingJavaScriptCoreRef,
addPreActionToScheme,
removePreActionFromScheme,
findInjectedXcodeproj,
readMarker,
readArtifactsVersionOverride,
readPinnedConfigCommand,
readScriptPhasesManifest,
assertPluginFrameworksLinked,
PluginFrameworkMismatchError,
SPM_INJECTED_MARKER,
};