UNPKG

react-native

Version:

A framework for building native apps using React

3,300 lines • 123 kB
/**
 * 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;')
    .replace(/\r/g, '&#13;')
    .replace(/\n/g, '&#10;')
    .replace(/\t/g, '&#9;');
}

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 `&quot;`, 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 `&quot;`, 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,
};