react-native 0.87.0-rc.0 → 0.87.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/Libraries/Core/InitializeCore.js +9 -1
  2. package/Libraries/Core/ReactNativeVersion.js +1 -1
  3. package/Libraries/ReactNative/AppRegistry.flow.js +1 -0
  4. package/Libraries/ReactNative/AppRegistryImpl.js +2 -3
  5. package/Libraries/ReactPrivate/ReactNativePrivateInterface.js +5 -116
  6. package/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow +5 -31
  7. package/React/Base/RCTVersion.m +1 -1
  8. package/React/I18n/RCTLocalizedString.mm +38 -2
  9. package/React-Core-prebuilt.podspec +45 -17
  10. package/React-Core.podspec +9 -2
  11. package/ReactAndroid/external-artifacts/build.gradle.kts +49 -0
  12. package/ReactAndroid/gradle.properties +1 -1
  13. package/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/SynchronousMountItem.kt +8 -2
  14. package/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt +1 -1
  15. package/ReactCommon/cxxreact/ReactNativeVersion.h +1 -1
  16. package/index.js +15 -3
  17. package/index.js.flow +0 -2
  18. package/package.json +27 -29
  19. package/react-native.config.js +81 -0
  20. package/scripts/cocoapods/fabric.rb +1 -1
  21. package/scripts/cocoapods/rncore.rb +35 -78
  22. package/scripts/cocoapods/rncore_facades.rb +232 -0
  23. package/scripts/cocoapods/rndependencies.rb +64 -3
  24. package/scripts/cocoapods/rndeps_facades.rb +193 -0
  25. package/scripts/cocoapods/spm.rb +78 -11
  26. package/scripts/codegen/templates/Package.swift.spm-template +97 -0
  27. package/scripts/react-native-xcode.sh +20 -0
  28. package/scripts/react_native_pods.rb +65 -16
  29. package/scripts/replace-rncore-version.js +53 -6
  30. package/scripts/setup-apple-spm.js +1165 -0
  31. package/scripts/spm/__doc__/rfc-spm-xcframework.md +707 -0
  32. package/scripts/spm/__doc__/spm-autolinking-plugins.md +244 -0
  33. package/scripts/spm/__doc__/spm-header-paths-contract.md +97 -0
  34. package/scripts/spm/__doc__/spm-plugins-assessment.md +128 -0
  35. package/scripts/spm/__doc__/spm-scripts.md +451 -0
  36. package/scripts/spm/autolinking-plugins.js +331 -0
  37. package/scripts/spm/download-spm-artifacts.js +1409 -0
  38. package/scripts/spm/expand-spm-dependencies.js +216 -0
  39. package/scripts/spm/flavored-frameworks.js +1008 -0
  40. package/scripts/spm/generate-spm-autolinking-config.js +161 -0
  41. package/scripts/spm/generate-spm-autolinking.js +1888 -0
  42. package/scripts/spm/generate-spm-package.js +302 -0
  43. package/scripts/spm/generate-spm-xcodeproj.js +2224 -0
  44. package/scripts/spm/read-podspec.js +695 -0
  45. package/scripts/spm/scaffold-package-swift.js +1206 -0
  46. package/scripts/spm/spm-pbxproj.js +654 -0
  47. package/scripts/spm/spm-types.js +517 -0
  48. package/scripts/spm/spm-utils.js +645 -0
  49. package/scripts/spm/sync-spm-autolinking.js +160 -0
  50. package/sdks/.hermesv1version +1 -0
  51. package/sdks/hermes-engine/utils/replace_hermes_version.js +18 -4
  52. package/sdks/hermes-engine/version.properties +1 -1
  53. package/src/asset-registry.js +1 -1
  54. package/src/react-private-interface.js +145 -0
  55. package/src/react-private-interface.js.flow +48 -0
  56. package/src/setup-env.js +22 -0
  57. package/src/unstable-internals-do-not-use.d.ts +214 -0
  58. package/src/unstable-internals-do-not-use.js +76 -0
  59. package/third-party-podspecs/ReactNativeDependencies.podspec +2 -2
  60. package/types_generated/Libraries/ReactNative/AppRegistry.flow.d.ts +2 -2
  61. package/types_generated/Libraries/ReactPrivate/ReactNativePrivateInterface.d.ts +6 -21
  62. package/types_generated/index.d.ts +1 -2
  63. package/types_generated/src/private/renderer/events/dispatchNativeEvent.d.ts +26 -0
  64. package/types_generated/src/react-private-interface.d.ts +33 -0
  65. package/Libraries/Utilities/SceneTracker.js +0 -42
  66. package/jest-preset.js +0 -26
  67. package/rn-get-polyfills.js +0 -13
  68. package/types/tsconfig.json +0 -17
  69. package/types_generated/Libraries/Components/Touchable/Touchable.d.ts +0 -261
  70. package/types_generated/tsconfig.test.json +0 -17
@@ -0,0 +1,2224 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow strict-local
8
+ * @format
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ /**
14
+ * generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager
15
+ * integration toolkit for an existing `<App>.xcodeproj`.
16
+ *
17
+ * `injectSpmIntoExistingXcodeproj` adds the SPM package references, React build
18
+ * settings, the "Sync SPM Autolinking" build phase, and a scheme pre-action to
19
+ * a user's existing project — purely additively, recording every edit in a
20
+ * `.spm-injected.json` marker. `removeSpmInjection` is the exact inverse (used
21
+ * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI.
22
+ */
23
+
24
+ const {readFlavoredFrameworksManifest} = require('./flavored-frameworks');
25
+ const {
26
+ addArrayMembers,
27
+ addArrayStringValues,
28
+ ensureScalarField,
29
+ findApplicationTargets,
30
+ findField,
31
+ findObjectByUuid,
32
+ findProjectObject,
33
+ insertObjectsIntoSection,
34
+ namespacedUUID,
35
+ quoteIfNeeded,
36
+ removeArrayMembersByUuid,
37
+ removeArrayStringValues,
38
+ removeDanglingJavaScriptCoreRef,
39
+ removeEmptyPodsGroup,
40
+ removeField,
41
+ removeObjectByUuid,
42
+ serializeEntry,
43
+ setScalarField,
44
+ } = require('./spm-pbxproj');
45
+ const {makeLogger, remotePackageConfig} = require('./spm-utils');
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+
49
+ /*:: import type {
50
+ FlavoredFrameworkManifestEntry,
51
+ XcframeworkSlice,
52
+ } from './spm-types'; */
53
+
54
+ const {log} = makeLogger('generate-spm-xcodeproj');
55
+
56
+ // Sidecar inside a USER-OWNED xcodeproj that SPM packages were injected into in
57
+ // place. Records the host project's root UUID + every edit so `spm deinit`
58
+ // (removeSpmInjection) can surgically revert and re-runs stay idempotent.
59
+ const SPM_INJECTED_MARKER = '.spm-injected.json';
60
+
61
+ // Manifest of plugin-contributed sources that must COMPILE INTO THE APP TARGET
62
+ // (e.g. Expo's ExpoModulesProvider.swift — an `@objc` class only reaches the
63
+ // ObjC classlist, and so is discoverable via NSClassFromString, when it
64
+ // compiles into the app target, NOT the static Autolinked aggregate). Written
65
+ // by generate-spm-autolinking.js (the plugin merge) BEFORE setupXcodeproj runs
66
+ // on both `add` and `update`, so the injector reads it synchronously. Path is
67
+ // relative to the app root (== SRCROOT / the .xcodeproj's dir).
68
+ const SPM_GENERATED_SOURCES_MANIFEST = path.join(
69
+ 'build',
70
+ 'generated',
71
+ 'autolinking',
72
+ '.spm-plugin-generated-sources.json',
73
+ );
74
+
75
+ // The single navigator group all injected generated sources are parented under
76
+ // (created on first use). Its namespacedUUID id + display name.
77
+ const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources';
78
+ const SPM_GENERATED_SOURCES_GROUP_NAME = 'SPM Generated Sources';
79
+
80
+ // pbxproj `lastKnownFileType` per source extension. v1 plugins emit Swift only;
81
+ // .m/.mm are mapped as future-proofing (the plugin contract permits ObjC/ObjC++
82
+ // sources). An unmapped extension is skipped with a loud log.
83
+ const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = {
84
+ '.swift': 'sourcecode.swift',
85
+ '.m': 'sourcecode.c.objc',
86
+ '.mm': 'sourcecode.cpp.objcpp',
87
+ };
88
+
89
+ // Maps each SPM product to its sub-package path (relative to app root).
90
+ // The xcodeproj must reference each sub-package directly so Xcode can
91
+ // resolve the product dependencies — SPM doesn't expose transitive products.
92
+ const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ =
93
+ [
94
+ {
95
+ product: 'ReactHeaders',
96
+ packagePath: 'build/xcframeworks',
97
+ packageName: 'ReactNative',
98
+ },
99
+ {
100
+ product: 'ReactNativeHeaders',
101
+ packagePath: 'build/xcframeworks',
102
+ packageName: 'ReactNative',
103
+ },
104
+ {
105
+ product: 'ReactNativeDependenciesHeaders',
106
+ packagePath: 'build/xcframeworks',
107
+ packageName: 'ReactNative',
108
+ },
109
+ {
110
+ product: 'Autolinked',
111
+ packagePath: 'build/generated/autolinking',
112
+ packageName: 'Autolinked',
113
+ },
114
+ {
115
+ product: 'ReactCodegen',
116
+ packagePath: 'build/generated/ios',
117
+ packageName: 'React-GeneratedCode',
118
+ },
119
+ {
120
+ product: 'ReactAppDependencyProvider',
121
+ packagePath: 'build/generated/ios',
122
+ packageName: 'React-GeneratedCode',
123
+ },
124
+ ];
125
+
126
+ /*::
127
+ type RemoteCfg = {url: string, version: string, identity: string};
128
+ // Precise record of the build-setting edits injection made to ONE build config,
129
+ // so deinit can reverse exactly those (and nothing the user already had).
130
+ type BuildSettingChange = {
131
+ configUuid: string,
132
+ createdArrayKeys: Array<string>,
133
+ appendedArrayValues: {[string]: Array<string>},
134
+ createdScalars: Array<string>,
135
+ // Scalars whose pre-injection value was replaced (key → original raw
136
+ // value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once
137
+ // CocoaPods is deintegrated. Deinit restores the original.
138
+ replacedScalars?: {[string]: string},
139
+ };
140
+ // A plugin-contributed source, normalized for pbxproj emission. `path` is
141
+ // SRCROOT-relative when under the app root, else absolute; `sourceTree` is the
142
+ // matching pbxproj token ('SOURCE_ROOT' or '"<absolute>"').
143
+ type GeneratedSource = {path: string, name: string, sourceTree: string, fileType: string};
144
+ type SpmGraph = {
145
+ uniquePackages: Array<{packagePath: string, packageName: string}>,
146
+ localPkgRefs: Array<{uuid: string, packagePath: string, comment: string}>,
147
+ remotePkgRef: ?{uuid: string, url: string, version: string, identity: string, comment: string},
148
+ products: Array<{product: string, depUuid: string, buildFileUuid: string, pkgRefUuid: string, refComment: string}>,
149
+ };
150
+ */
151
+
152
+ /**
153
+ * Resolve the SPM dependency graph (package references + product
154
+ * dependencies + their frameworks build files) from SPM_PRODUCT_PACKAGES.
155
+ * `mkUuid(section, id)` supplies UUIDs, seeded with the host project's root
156
+ * UUID so injected IDs are stable across re-runs and collision-safe.
157
+ */
158
+ function buildSpmDependencyGraph(
159
+ mkUuid /*: (section: string, id: string) => string */,
160
+ remote /*: ?RemoteCfg */,
161
+ ) /*: SpmGraph */ {
162
+ // Remote mode: ReactNative-family products move to the remote package.
163
+ const productPackages = SPM_PRODUCT_PACKAGES.map(e =>
164
+ remote != null && e.packagePath === 'build/xcframeworks'
165
+ ? {...e, packagePath: 'REMOTE', packageName: remote.identity}
166
+ : e,
167
+ );
168
+ const uniquePackages = Array.from(
169
+ new Map(
170
+ productPackages
171
+ .filter(e => e.packagePath !== 'REMOTE')
172
+ .map(e => [
173
+ e.packagePath,
174
+ {packagePath: e.packagePath, packageName: e.packageName},
175
+ ]),
176
+ ).values(),
177
+ );
178
+ const localPkgRefs = uniquePackages.map(pkg => ({
179
+ uuid: mkUuid('XCLocalSwiftPackageReference', pkg.packagePath),
180
+ packagePath: pkg.packagePath,
181
+ comment: `XCLocalSwiftPackageReference "${pkg.packagePath}"`,
182
+ }));
183
+ const remotePkgRef =
184
+ remote != null
185
+ ? {
186
+ uuid: mkUuid('XCRemoteSwiftPackageReference', remote.url),
187
+ url: remote.url,
188
+ version: remote.version,
189
+ identity: remote.identity,
190
+ comment: `XCRemoteSwiftPackageReference "${remote.identity}"`,
191
+ }
192
+ : null;
193
+ const localByPath = new Map(localPkgRefs.map(r => [r.packagePath, r]));
194
+ const products = productPackages.map(entry => {
195
+ const {product, packagePath} = entry;
196
+ const isRemote = packagePath === 'REMOTE' && remotePkgRef != null;
197
+ const pkgRefUuid = isRemote
198
+ ? // $FlowFixMe[incompatible-use] guarded by isRemote
199
+ remotePkgRef.uuid
200
+ : // $FlowFixMe[incompatible-use] every non-REMOTE path is in localByPath
201
+ localByPath.get(packagePath).uuid;
202
+ const refComment = isRemote
203
+ ? // $FlowFixMe[incompatible-use] guarded by isRemote
204
+ `XCRemoteSwiftPackageReference "${remotePkgRef.identity}"`
205
+ : `XCLocalSwiftPackageReference "${packagePath}"`;
206
+ return {
207
+ product,
208
+ depUuid: mkUuid('XCSwiftPackageProductDependency', product),
209
+ buildFileUuid: mkUuid('PBXBuildFile', `spm:${product}`),
210
+ pkgRefUuid,
211
+ refComment,
212
+ };
213
+ });
214
+ return {uniquePackages, localPkgRefs, remotePkgRef, products};
215
+ }
216
+
217
+ /**
218
+ * Render the SPM graph into pbxproj section entry objects the in-place injector
219
+ * splices into an existing project.
220
+ */
221
+ /*:: type PbxEntryT = {uuid: string, comment: string, fields: {[string]: string}}; */
222
+
223
+ function spmGraphToEntries(
224
+ graph /*: SpmGraph */,
225
+ ) /*: {localRefs: Array<PbxEntryT>, remoteRef: ?PbxEntryT, productDeps: Array<PbxEntryT>, buildFiles: Array<PbxEntryT>} */ {
226
+ const localRefs /*: Array<PbxEntryT> */ = graph.localPkgRefs.map(ref => ({
227
+ uuid: ref.uuid,
228
+ comment: ref.comment,
229
+ fields: {
230
+ isa: 'XCLocalSwiftPackageReference',
231
+ relativePath: quoteIfNeeded(ref.packagePath),
232
+ },
233
+ }));
234
+ const remote = graph.remotePkgRef;
235
+ const remoteRef /*: ?PbxEntryT */ =
236
+ remote != null
237
+ ? {
238
+ uuid: remote.uuid,
239
+ comment: remote.comment,
240
+ fields: {
241
+ isa: 'XCRemoteSwiftPackageReference',
242
+ repositoryURL: quoteIfNeeded(remote.url),
243
+ requirement: `{\n\t\t\t\tkind = exactVersion;\n\t\t\t\tversion = "${remote.version}";\n\t\t\t}`,
244
+ },
245
+ }
246
+ : null;
247
+ const productDeps /*: Array<PbxEntryT> */ = graph.products.map(p => ({
248
+ uuid: p.depUuid,
249
+ comment: p.product,
250
+ fields: {
251
+ isa: 'XCSwiftPackageProductDependency',
252
+ package: `${p.pkgRefUuid} /* ${p.refComment} */`,
253
+ productName: quoteIfNeeded(p.product),
254
+ },
255
+ }));
256
+ const buildFiles /*: Array<PbxEntryT> */ = graph.products.map(p => ({
257
+ uuid: p.buildFileUuid,
258
+ comment: `${p.product} in Frameworks`,
259
+ fields: {
260
+ isa: 'PBXBuildFile',
261
+ productRef: `${p.depUuid} /* ${p.product} */`,
262
+ },
263
+ }));
264
+ return {localRefs, remoteRef, productDeps, buildFiles};
265
+ }
266
+
267
+ // Sync SPM Autolinking: timestamp check + conditional node re-run. Shared by
268
+ // the build phase (safety net) and the scheme pre-action (the one that
269
+ // actually fires before SPM resolution, so a single build picks up
270
+ // dep-graph changes from `npm install`).
271
+ // Build a PBXShellScriptBuildPhase entry (the "Sync SPM Autolinking" phase).
272
+ function shellScriptPhase(
273
+ phaseUUID /*: string */,
274
+ name /*: string */,
275
+ script /*: string */,
276
+ options /*: {inputPaths?: string, outputPaths?: string} */ = {},
277
+ ) /*: {uuid: string, comment: string, fields: {[string]: string}} */ {
278
+ const empty = '(\n\t\t\t)';
279
+ return {
280
+ uuid: phaseUUID,
281
+ comment: name,
282
+ fields: {
283
+ isa: 'PBXShellScriptBuildPhase',
284
+ buildActionMask: '2147483647',
285
+ files: empty,
286
+ inputFileListPaths: empty,
287
+ inputPaths: options.inputPaths ?? empty,
288
+ name: quoteIfNeeded(name),
289
+ outputFileListPaths: empty,
290
+ outputPaths: options.outputPaths ?? empty,
291
+ runOnlyForDeploymentPostprocessing: '0',
292
+ shellPath: '/bin/sh',
293
+ shellScript: quoteIfNeeded(script),
294
+ },
295
+ };
296
+ }
297
+
298
+ function frameworkSettingPrefix(id /*: string */) /*: string */ {
299
+ return `RN_SPM_${id.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`;
300
+ }
301
+
302
+ function flavorForBuildConfiguration(
303
+ configurationName /*: string */,
304
+ ) /*: 'debug' | 'release' */ {
305
+ const lower = configurationName.toLowerCase();
306
+ return lower.includes('debug') || lower.includes('development')
307
+ ? 'debug'
308
+ : 'release';
309
+ }
310
+
311
+ function buildConfigurationName(
312
+ text /*: string */,
313
+ configUuid /*: string */,
314
+ ) /*: string */ {
315
+ const config = findObjectByUuid(text, configUuid);
316
+ const name = config != null ? findField(text, config, 'name') : null;
317
+ if (name == null) {
318
+ throw new Error(`pbxproj: build configuration ${configUuid} has no name`);
319
+ }
320
+ return name.value.replace(/^"|"$/g, '');
321
+ }
322
+
323
+ function frameworkConditionalSettings(
324
+ frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
325
+ ) /*: Array<{key: string, value: string}> */ {
326
+ const settings /*: Array<{key: string, value: string}> */ = [];
327
+ for (const framework of frameworks) {
328
+ const prefix = frameworkSettingPrefix(framework.id);
329
+ const bySdk /*: Map<string, Array<XcframeworkSlice>> */ = new Map();
330
+ // The injected target is an Apple mobile/Catalyst application. Native
331
+ // macOS slices share `sdk=macosx*` with Catalyst and cannot be
332
+ // distinguished by an XCBuildConfiguration condition, so use the Catalyst
333
+ // slice and leave native-mac packaging out of this iOS integration.
334
+ for (const slice of framework.slices.filter(
335
+ candidate => candidate.platform !== 'macos',
336
+ )) {
337
+ const existing = bySdk.get(slice.sdk) ?? [];
338
+ existing.push(slice);
339
+ bySdk.set(slice.sdk, existing);
340
+ }
341
+ for (const [sdk, slices] of bySdk) {
342
+ const emit = (slice /*: XcframeworkSlice */, condition /*: string */) => {
343
+ const root =
344
+ `$(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/` +
345
+ `${framework.artifactRelativePath}/${slice.libraryIdentifier}`;
346
+ settings.push(
347
+ {
348
+ key: quoteIfNeeded(`${prefix}_FRAMEWORK${condition}`),
349
+ value: quoteIfNeeded(`${root}/${slice.libraryPath}`),
350
+ },
351
+ {
352
+ key: quoteIfNeeded(`${prefix}_BINARY${condition}`),
353
+ value: quoteIfNeeded(`${root}/${slice.binaryPath}`),
354
+ },
355
+ {
356
+ key: quoteIfNeeded(`${prefix}_SEARCH_PATH${condition}`),
357
+ value: quoteIfNeeded(root),
358
+ },
359
+ );
360
+ };
361
+ if (slices.length === 1) {
362
+ emit(slices[0], `[sdk=${sdk}]`);
363
+ continue;
364
+ }
365
+ const seenArchitectures /*: Set<string> */ = new Set();
366
+ for (const slice of slices) {
367
+ for (const architecture of slice.architectures) {
368
+ if (seenArchitectures.has(architecture)) {
369
+ throw new Error(
370
+ `${framework.frameworkName} has ambiguous ${sdk}/${architecture} slices`,
371
+ );
372
+ }
373
+ seenArchitectures.add(architecture);
374
+ emit(slice, `[sdk=${sdk}][arch=${architecture}]`);
375
+ }
376
+ }
377
+ }
378
+ }
379
+ return settings;
380
+ }
381
+
382
+ function frameworkArrayBuildSettings(
383
+ frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
384
+ ) /*: Array<{key: string, values: Array<string>}> */ {
385
+ return [
386
+ {
387
+ key: 'OTHER_LDFLAGS',
388
+ values: [
389
+ '"-ObjC"',
390
+ ...frameworks.map(
391
+ framework => `"$(${frameworkSettingPrefix(framework.id)}_BINARY)"`,
392
+ ),
393
+ ],
394
+ },
395
+ {
396
+ key: 'FRAMEWORK_SEARCH_PATHS',
397
+ values: frameworks.map(
398
+ framework => `"$(${frameworkSettingPrefix(framework.id)}_SEARCH_PATH)"`,
399
+ ),
400
+ },
401
+ {
402
+ key: 'LD_RUNPATH_SEARCH_PATHS',
403
+ values: ['"@executable_path/Frameworks"'],
404
+ },
405
+ ];
406
+ }
407
+
408
+ function pbxPathList(paths /*: ReadonlyArray<string> */) /*: string */ {
409
+ if (paths.length === 0) {
410
+ return '(\n\t\t\t)';
411
+ }
412
+ return `(\n${paths
413
+ .map(value => `\t\t\t\t${quoteIfNeeded(value)},\n`)
414
+ .join('')}\t\t\t)`;
415
+ }
416
+
417
+ function buildEmbedFrameworksScript(
418
+ frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
419
+ ) /*: string */ {
420
+ const validations = frameworks
421
+ .map(framework => {
422
+ const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`;
423
+ return `validate_framework "\${${variable}:-}" "${framework.frameworkName}.framework"`;
424
+ })
425
+ .join('\n');
426
+ const copies = frameworks
427
+ .map(framework => {
428
+ const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`;
429
+ return `copy_and_sign "\${${variable}:-}" "${framework.frameworkName}.framework"`;
430
+ })
431
+ .join('\n');
432
+ return `set -euo pipefail
433
+
434
+ destination="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH"
435
+ mkdir -p "$destination"
436
+
437
+ validate_framework() {
438
+ source="$1"
439
+ name="$2"
440
+ if [ -z "$source" ] || [ ! -d "$source" ]; then
441
+ echo "error: React Native SwiftPM framework '$name' is unavailable for configuration '$CONFIGURATION' and SDK '$SDK_NAME': $source"
442
+ exit 1
443
+ fi
444
+ binary="\${name%.framework}"
445
+ if [ ! -e "$source/$binary" ] && [ ! -e "$source/Versions/Current/$binary" ]; then
446
+ echo "error: React Native SwiftPM framework '$name' is invalid for configuration '$CONFIGURATION': expected $source/$binary or $source/Versions/Current/$binary"
447
+ exit 1
448
+ fi
449
+ }
450
+
451
+ copy_and_sign() {
452
+ source="$1"
453
+ name="$2"
454
+ /usr/bin/rsync -a --delete "$source/" "$destination/$name/"
455
+ if [ "\${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]; then
456
+ identity="\${EXPANDED_CODE_SIGN_IDENTITY:--}"
457
+ if [ "$identity" = "-" ]; then
458
+ /usr/bin/codesign --force --sign - --timestamp=none --preserve-metadata=identifier,entitlements,flags "$destination/$name"
459
+ else
460
+ /usr/bin/codesign --force --sign "$identity" --preserve-metadata=identifier,entitlements,flags "$destination/$name"
461
+ fi
462
+ fi
463
+ }
464
+
465
+ ${validations}
466
+ ${copies}
467
+ `;
468
+ }
469
+
470
+ function addBuildPhaseAfter(
471
+ text /*: string */,
472
+ target /*: {bodyOpen: number, bodyClose: number, ...} */,
473
+ afterUuid /*: string */,
474
+ member /*: {uuid: string, comment: string} */,
475
+ ) /*: string */ {
476
+ const field = findField(text, target, 'buildPhases');
477
+ if (field == null || field.value.includes(member.uuid)) {
478
+ return text;
479
+ }
480
+ const after = new RegExp(`(^|\\n)([\\t ]*)${afterUuid}\\b[^\\n]*,`).exec(
481
+ field.value,
482
+ );
483
+ if (after == null) {
484
+ return addArrayMembers(text, target, 'buildPhases', [member]);
485
+ }
486
+ const absoluteStart = field.valueStart + after.index;
487
+ const lineEnd = text.indexOf('\n', absoluteStart + after[0].length);
488
+ const indent = after[2];
489
+ const line = `\n${indent}${member.uuid} /* ${member.comment} */,`;
490
+ return text.slice(0, lineEnd) + line + text.slice(lineEnd);
491
+ }
492
+
493
+ // The node + react-native-dir resolution preamble shared by the sync build
494
+ // phase and scheme pre-action. Both dispatch DIRECTLY into react-native's
495
+ // scripts rather than through 'npx react-native' — that CLI requires
496
+ // @react-native-community/cli (absent in e.g. Expo apps), so it would exit
497
+ // non-zero and the failure would be silently swallowed.
498
+ function nodeAndRnDirPreamble(reactNativePath /*: string */) /*: string */ {
499
+ return `set -euo pipefail
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # Resolve a node binary and the react-native package dir at BUILD TIME.
503
+ # ---------------------------------------------------------------------------
504
+ NODE_BINARY="\${NODE_BINARY:-}"
505
+ if [ -z "$NODE_BINARY" ]; then
506
+ # Source RN's standard app-local node-path files. They reference vars that
507
+ # may be unset and may return non-zero, so relax nounset AND errexit while
508
+ # sourcing — a buggy user .xcode.env must degrade to PATH-based node
509
+ # resolution below, not silently abort every build.
510
+ set +eu
511
+ if [ -f "$SRCROOT/.xcode.env" ]; then
512
+ . "$SRCROOT/.xcode.env"
513
+ fi
514
+ if [ -f "$SRCROOT/.xcode.env.local" ]; then
515
+ . "$SRCROOT/.xcode.env.local"
516
+ fi
517
+ set -eu
518
+ NODE_BINARY="\${NODE_BINARY:-}"
519
+ fi
520
+ if [ -z "$NODE_BINARY" ]; then
521
+ NODE_BINARY="$(command -v node 2>/dev/null || true)"
522
+ fi
523
+
524
+ # Resolve react-native's dir FROM THE APP (require.resolve), not a
525
+ # generation-time baked path — the baked path goes stale in pnpm / hoisted
526
+ # stores. Fall back to the baked path if resolution fails or the resolved dir
527
+ # has no setup-apple-spm.js.
528
+ RN_DIR=""
529
+ if [ -n "$NODE_BINARY" ]; then
530
+ RN_DIR="$(cd "$SRCROOT" && "$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json'))" 2>/dev/null || true)"
531
+ fi
532
+ if [ -z "$RN_DIR" ] || [ ! -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then
533
+ RN_DIR="${reactNativePath}"
534
+ fi`;
535
+ }
536
+
537
+ // Shared: the STALE-input check + conditional codegen/autolinking sync dispatch.
538
+ // Runtime framework slots are never touched here; add/update owns them.
539
+ function syncStaleCheckAndDispatch() /*: string */ {
540
+ return `STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp"
541
+ STALE=0
542
+
543
+ # Find project root (where package.json lives — may be an ancestor of SRCROOT)
544
+ PROJECT_ROOT="$SRCROOT"
545
+ while [ "$PROJECT_ROOT" != "/" ] && [ ! -f "$PROJECT_ROOT/package.json" ]; do
546
+ PROJECT_ROOT="$(dirname "$PROJECT_ROOT")"
547
+ done
548
+ if [ ! -f "$PROJECT_ROOT/package.json" ]; then
549
+ PROJECT_ROOT="$SRCROOT"
550
+ fi
551
+
552
+ # Check 1: dependency inputs (covers app projects after any package manager install)
553
+ for INPUT in \\
554
+ "$PROJECT_ROOT/package.json" \\
555
+ "$PROJECT_ROOT/react-native.config.js"; do
556
+ if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then
557
+ STALE=1
558
+ break
559
+ fi
560
+ done
561
+
562
+ # Check workspace lockfiles and package-manager metadata. These cover package
563
+ # managers that do not reliably bump node_modules mtimes, and Yarn PnP projects
564
+ # that do not have node_modules at all.
565
+ if [ "$STALE" -eq 0 ]; then
566
+ DIR="$PROJECT_ROOT"
567
+ while [ "$DIR" != "/" ]; do
568
+ for INPUT in \\
569
+ "$DIR/package-lock.json" \\
570
+ "$DIR/npm-shrinkwrap.json" \\
571
+ "$DIR/yarn.lock" \\
572
+ "$DIR/pnpm-lock.yaml" \\
573
+ "$DIR/bun.lock" \\
574
+ "$DIR/bun.lockb" \\
575
+ "$DIR/.pnp.cjs" \\
576
+ "$DIR/.pnp.loader.mjs"; do
577
+ if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then
578
+ STALE=1
579
+ break
580
+ fi
581
+ done
582
+ if [ "$STALE" -eq 1 ]; then
583
+ break
584
+ fi
585
+ DIR="$(dirname "$DIR")"
586
+ done
587
+ fi
588
+
589
+ # Check node_modules mtime. In monorepos, node_modules may be hoisted to any
590
+ # ancestor between the app package and the workspace root.
591
+ if [ "$STALE" -eq 0 ]; then
592
+ DIR="$PROJECT_ROOT"
593
+ while [ "$DIR" != "/" ]; do
594
+ NM_DIR="$DIR/node_modules"
595
+ if [ -d "$NM_DIR" ] && [ "$NM_DIR" -nt "$STAMP" ]; then
596
+ STALE=1
597
+ break
598
+ fi
599
+ DIR="$(dirname "$DIR")"
600
+ done
601
+ fi
602
+
603
+ # Also check the app root directly when SRCROOT is not the package root.
604
+ if [ "$STALE" -eq 0 ] && [ "$SRCROOT" != "$PROJECT_ROOT" ]; then
605
+ if [ -d "$SRCROOT/node_modules" ] && [ "$SRCROOT/node_modules" -nt "$STAMP" ]; then
606
+ STALE=1
607
+ fi
608
+ fi
609
+
610
+ # Check 1.5: watched paths (mixed dirs AND files). Dirs catch add/remove of
611
+ # source files in spm.modules and autolinked deps (dir mtime updates on both);
612
+ # files catch edits to a dep's checked-in Package.swift / plugin manifests that
613
+ # would not bump any parent dir mtime. A path that has VANISHED (renamed/moved
614
+ # module root) forces a re-sync so the autolinker surfaces the real, actionable
615
+ # config error rather than the build failing later on dangling-symlink noise.
616
+ WATCH_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-paths"
617
+ if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then
618
+ while IFS= read -r P; do
619
+ [ -z "$P" ] && continue
620
+ if [ -d "$P" ]; then
621
+ if [ -n "$(find "$P" -newer "$STAMP" -print -quit 2>/dev/null)" ]; then
622
+ STALE=1
623
+ break
624
+ fi
625
+ elif [ -f "$P" ]; then
626
+ if [ "$P" -nt "$STAMP" ]; then
627
+ STALE=1
628
+ break
629
+ fi
630
+ else
631
+ STALE=1
632
+ break
633
+ fi
634
+ done < "$WATCH_FILE"
635
+ fi
636
+
637
+ # Check 2: codegen spec files changed via git (covers monorepo after git pull)
638
+ if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then
639
+ STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
640
+ LATEST_SPEC_COMMIT=$(git -C "$SRCROOT" log -1 --format=%ct -- '*.js' '*.ts' 2>/dev/null || echo 0)
641
+ if [ "$LATEST_SPEC_COMMIT" -gt "$STAMP_TIME" ]; then
642
+ STALE=1
643
+ fi
644
+ fi
645
+
646
+ if [ ! -f "$STAMP" ]; then
647
+ STALE=1
648
+ fi
649
+
650
+ # Re-sync codegen + autolinking when a dependency input changed. Runtime
651
+ # framework slots and Xcode linker settings are only changed by spm update.
652
+ if [ "$STALE" -eq 1 ]; then
653
+ echo "SPM sync inputs changed — re-syncing (codegen + autolinking)..."
654
+
655
+ WITH_ENVIRONMENT="$RN_DIR/scripts/xcode/with-environment.sh"
656
+
657
+ if [ -f "$WITH_ENVIRONMENT" ]; then
658
+ # with-environment.sh references PODS_ROOT and $1, which may be unset.
659
+ # Temporarily disable nounset to avoid failures when sourcing.
660
+ export PODS_ROOT="\${PODS_ROOT:-$SRCROOT}"
661
+ set +u
662
+ . "$WITH_ENVIRONMENT"
663
+ set -u
664
+ fi
665
+
666
+ cd "$SRCROOT"
667
+ # \`|| RC=$?\` so a non-zero exit is CAPTURED rather than aborting the phase
668
+ # under \`set -e\` — the whole point is to branch on the code below (2 = fail
669
+ # the build with a scaffold hint; other non-zero = warn but don't break).
670
+ RC=0
671
+ if [ -n "$NODE_BINARY" ] && [ -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then
672
+ # Direct, dependency-free dispatch (no \`npx react-native\`, which needs
673
+ # @react-native-community/cli).
674
+ "$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync || RC=$?
675
+ elif command -v npx >/dev/null 2>&1; then
676
+ npx react-native spm sync || RC=$?
677
+ else
678
+ echo "warning: node/npx not found — skipping SPM sync"
679
+ fi
680
+ if [ "$RC" -eq 2 ]; then
681
+ # Exit 2 = an autolinked community dependency has no Package.swift. The
682
+ # autolinker already printed an \`error:\` line per dep (so Xcode shows them
683
+ # and the fix). Fail the build — the developer must run
684
+ # \`npx react-native spm scaffold\` from a terminal to generate the manifest.
685
+ exit 1
686
+ elif [ "$RC" -ne 0 ]; then
687
+ echo "warning: SPM sync failed — build may use stale codegen/autolinking"
688
+ fi
689
+ fi
690
+ `;
691
+ }
692
+
693
+ // Scheme pre-action: re-run codegen + autolinking before package resolution.
694
+ function buildSchemePreActionScript(
695
+ reactNativePath /*: string */,
696
+ ) /*: string */ {
697
+ return `${nodeAndRnDirPreamble(reactNativePath)}
698
+
699
+ ${syncStaleCheckAndDispatch()}
700
+ `;
701
+ }
702
+
703
+ // The in-target phase is only an autolinking safety net. Runtime framework
704
+ // selection is expressed entirely through build settings and the independent
705
+ // Embed React Native Flavored Frameworks phase.
706
+ function buildSyncAutolinkingScript(
707
+ reactNativePath /*: string */,
708
+ ) /*: string */ {
709
+ return `${nodeAndRnDirPreamble(reactNativePath)}
710
+
711
+ ${syncStaleCheckAndDispatch()}
712
+ `;
713
+ }
714
+ // XML-attribute escape (the five named entities). The sync script uses `>`
715
+ // and `&` for redirection and bg/and chains, plus `<` for heredocs and
716
+ // comparisons — all of which break Xcode's scheme parser if left raw.
717
+ function escapeXmlAttribute(s /*: string */) /*: string */ {
718
+ return s
719
+ .replace(/&/g, '&amp;')
720
+ .replace(/</g, '&lt;')
721
+ .replace(/>/g, '&gt;')
722
+ .replace(/"/g, '&quot;')
723
+ .replace(/'/g, '&apos;');
724
+ }
725
+
726
+ function generateXcscheme(
727
+ appName /*: string */,
728
+ targetUUID /*: string */,
729
+ projName /*: string */,
730
+ syncScript /*: string */,
731
+ ) /*: string */ {
732
+ const escapedSync = escapeXmlAttribute(syncScript);
733
+ return `<?xml version="1.0" encoding="UTF-8"?>
734
+ <Scheme
735
+ LastUpgradeVersion = "1600"
736
+ version = "1.7">
737
+ <BuildAction
738
+ parallelizeBuildables = "YES"
739
+ buildImplicitDependencies = "YES">
740
+ <PreActions>
741
+ <ExecutionAction
742
+ ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
743
+ <ActionContent
744
+ title = "Sync SPM Autolinking"
745
+ scriptText = "${escapedSync}">
746
+ <EnvironmentBuildable>
747
+ <BuildableReference
748
+ BuildableIdentifier = "primary"
749
+ BlueprintIdentifier = "${targetUUID}"
750
+ BuildableName = "${appName}.app"
751
+ BlueprintName = "${appName}"
752
+ ReferencedContainer = "container:${projName}.xcodeproj">
753
+ </BuildableReference>
754
+ </EnvironmentBuildable>
755
+ </ActionContent>
756
+ </ExecutionAction>
757
+ </PreActions>
758
+ <BuildActionEntries>
759
+ <BuildActionEntry
760
+ buildForTesting = "YES"
761
+ buildForRunning = "YES"
762
+ buildForProfiling = "YES"
763
+ buildForArchiving = "YES"
764
+ buildForAnalyzing = "YES">
765
+ <BuildableReference
766
+ BuildableIdentifier = "primary"
767
+ BlueprintIdentifier = "${targetUUID}"
768
+ BuildableName = "${appName}.app"
769
+ BlueprintName = "${appName}"
770
+ ReferencedContainer = "container:${projName}.xcodeproj">
771
+ </BuildableReference>
772
+ </BuildActionEntry>
773
+ </BuildActionEntries>
774
+ </BuildAction>
775
+ <TestAction
776
+ buildConfiguration = "Debug"
777
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
778
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
779
+ shouldUseLaunchSchemeArgsEnv = "YES"
780
+ shouldAutocreateTestPlan = "YES">
781
+ </TestAction>
782
+ <LaunchAction
783
+ buildConfiguration = "Debug"
784
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
785
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
786
+ launchStyle = "0"
787
+ useCustomWorkingDirectory = "NO"
788
+ ignoresPersistentStateOnLaunch = "NO"
789
+ debugDocumentVersioning = "YES"
790
+ debugServiceExtension = "internal"
791
+ allowLocationSimulation = "YES">
792
+ <BuildableProductRunnable
793
+ runnableDebuggingMode = "0">
794
+ <BuildableReference
795
+ BuildableIdentifier = "primary"
796
+ BlueprintIdentifier = "${targetUUID}"
797
+ BuildableName = "${appName}.app"
798
+ BlueprintName = "${appName}"
799
+ ReferencedContainer = "container:${projName}.xcodeproj">
800
+ </BuildableReference>
801
+ </BuildableProductRunnable>
802
+ </LaunchAction>
803
+ <ProfileAction
804
+ buildConfiguration = "Release"
805
+ shouldUseLaunchSchemeArgsEnv = "YES"
806
+ savedToolIdentifier = ""
807
+ useCustomWorkingDirectory = "NO"
808
+ debugDocumentVersioning = "YES">
809
+ <BuildableProductRunnable
810
+ runnableDebuggingMode = "0">
811
+ <BuildableReference
812
+ BuildableIdentifier = "primary"
813
+ BlueprintIdentifier = "${targetUUID}"
814
+ BuildableName = "${appName}.app"
815
+ BlueprintName = "${appName}"
816
+ ReferencedContainer = "container:${projName}.xcodeproj">
817
+ </BuildableReference>
818
+ </BuildableProductRunnable>
819
+ </ProfileAction>
820
+ <AnalyzeAction
821
+ buildConfiguration = "Debug">
822
+ </AnalyzeAction>
823
+ <ArchiveAction
824
+ buildConfiguration = "Release"
825
+ revealArchiveInOrganizer = "YES">
826
+ </ArchiveAction>
827
+ </Scheme>
828
+ `;
829
+ }
830
+
831
+ // When the xcodeproj is generated, the referenced SPM package directories
832
+ // (build/xcframeworks, autolinked, build/generated/ios) may not exist yet.
833
+ // Xcode resolves packages before any build phase runs, so we write minimal
834
+ // stub Package.swift files to let resolution succeed. The real generators
835
+ // (sync-spm-autolinking.js) overwrite these during the first build.
836
+
837
+ /*::
838
+ type StubPackageDef = {
839
+ packageName: string,
840
+ products: Array<string>,
841
+ };
842
+ */
843
+
844
+ function generateStubPackageSwift(def /*: StubPackageDef */) /*: string */ {
845
+ const {packageName, products} = def;
846
+ const stubTarget = `${packageName.replace(/[^a-zA-Z0-9]/g, '')}Stub`;
847
+ const productLines = products
848
+ .map(p => ` .library(name: "${p}", targets: ["${stubTarget}"]),`)
849
+ .join('\n');
850
+ return `// swift-tools-version: 5.9
851
+ // GENERATED STUB — will be overwritten by sync-spm-autolinking.js during build.
852
+ import PackageDescription
853
+
854
+ let package = Package(
855
+ name: "${packageName}",
856
+ products: [
857
+ ${productLines}
858
+ ],
859
+ targets: [
860
+ .target(name: "${stubTarget}", path: "_stub", sources: ["Stub.swift"]),
861
+ ]
862
+ )
863
+ `;
864
+ }
865
+
866
+ /**
867
+ * Ensures each referenced SPM sub-package directory has a valid Package.swift
868
+ * so Xcode can resolve packages before any build phase runs.
869
+ * Skips directories that already contain a Package.swift (from a previous build).
870
+ */
871
+ function ensureStubPackages(appRoot /*: string */) /*: void */ {
872
+ // Derive stub definitions from SPM_PRODUCT_PACKAGES
873
+ const byPath = new Map /*:: <string, StubPackageDef> */();
874
+ for (const entry of SPM_PRODUCT_PACKAGES) {
875
+ const existing = byPath.get(entry.packagePath);
876
+ if (existing != null) {
877
+ existing.products.push(entry.product);
878
+ } else {
879
+ byPath.set(entry.packagePath, {
880
+ packageName: entry.packageName,
881
+ products: [entry.product],
882
+ });
883
+ }
884
+ }
885
+
886
+ for (const [relPath, def] of byPath) {
887
+ const pkgDir = path.join(appRoot, relPath);
888
+ const pkgSwiftPath = path.join(pkgDir, 'Package.swift');
889
+
890
+ if (fs.existsSync(pkgSwiftPath)) {
891
+ continue;
892
+ }
893
+
894
+ fs.mkdirSync(pkgDir, {recursive: true});
895
+ fs.writeFileSync(pkgSwiftPath, generateStubPackageSwift(def), 'utf8');
896
+
897
+ // Create minimal stub source file required by SPM
898
+ const stubDir = path.join(pkgDir, '_stub');
899
+ fs.mkdirSync(stubDir, {recursive: true});
900
+ const stubSwift = path.join(stubDir, 'Stub.swift');
901
+ if (!fs.existsSync(stubSwift)) {
902
+ fs.writeFileSync(
903
+ stubSwift,
904
+ '// Placeholder — replaced during first build.\n',
905
+ 'utf8',
906
+ );
907
+ }
908
+
909
+ log(`Wrote stub Package.swift: ${relPath}/Package.swift`);
910
+ }
911
+ }
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // In-place injection: add SPM packages to a user's EXISTING xcodeproj.
915
+ //
916
+ // This never creates a target or scans sources — it splices the SPM dependency
917
+ // graph, the React build settings, and the sync build phase / scheme pre-action
918
+ // into the project the user already owns, leaving everything else
919
+ // byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so
920
+ // hand-tuned signing / capabilities / extra targets survive. Fails loud (the
921
+ // caller surfaces the error) when the project is CocoaPods-integrated or its
922
+ // shape can't be safely anchored.
923
+ // ---------------------------------------------------------------------------
924
+
925
+ // The React build settings the app target needs to compile against the SPM
926
+ // products.
927
+ const INJECTED_ARRAY_SETTINGS = [
928
+ {
929
+ key: 'HEADER_SEARCH_PATHS',
930
+ values: ['"$(SRCROOT)/build/generated/autolinking/headers"'],
931
+ },
932
+ ];
933
+
934
+ /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */
935
+ function targetBuildConfigUuids(
936
+ text /*: string */,
937
+ targetObj /*: {bodyOpen: number, bodyClose: number, ...} */,
938
+ ) /*: Array<string> */ {
939
+ const listField = findField(text, targetObj, 'buildConfigurationList');
940
+ if (listField == null) {
941
+ return [];
942
+ }
943
+ const listMatch = listField.value.match(/[0-9A-Fa-f]{24}/);
944
+ if (listMatch == null) {
945
+ return [];
946
+ }
947
+ const listObj = findObjectByUuid(text, listMatch[0]);
948
+ if (listObj == null) {
949
+ return [];
950
+ }
951
+ const configs = findField(text, listObj, 'buildConfigurations');
952
+ if (configs == null) {
953
+ return [];
954
+ }
955
+ const matches = configs.value.match(/[0-9A-Fa-f]{24}/g);
956
+ return matches != null ? Array.from(matches) : [];
957
+ }
958
+
959
+ /** True when a build config layers a CocoaPods `Pods-*.xcconfig`. */
960
+ function configUsesPods(
961
+ text /*: string */,
962
+ configUuid /*: string */,
963
+ ) /*: boolean */ {
964
+ const obj = findObjectByUuid(text, configUuid);
965
+ if (obj == null) {
966
+ return false;
967
+ }
968
+ const base = findField(text, obj, 'baseConfigurationReference');
969
+ return base != null && /Pods[-/]/.test(base.value);
970
+ }
971
+
972
+ /**
973
+ * Inspect an existing pbxproj and decide whether it can be injected. Returns
974
+ * the chosen app target + its config/frameworks anchors, or a refusal reason
975
+ * the caller surfaces (fail-loud).
976
+ */
977
+ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*:
978
+ | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array<string>, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string}
979
+ | {ok: false, reason: string} */ {
980
+ const project = findProjectObject(text);
981
+ if (project == null) {
982
+ return {ok: false, reason: 'no PBXProject object found'};
983
+ }
984
+ const apps = findApplicationTargets(text);
985
+ if (apps.length === 0) {
986
+ return {ok: false, reason: 'no application target found'};
987
+ }
988
+ let target;
989
+ if (apps.length === 1) {
990
+ target = apps[0];
991
+ } else {
992
+ const appName = opts.appName;
993
+ if (appName == null) {
994
+ return {
995
+ ok: false,
996
+ reason: `multiple application targets (${apps
997
+ .map(a => a.name)
998
+ .join(', ')}); pass --app-name to disambiguate`,
999
+ };
1000
+ }
1001
+ target = apps.find(a => a.name === appName);
1002
+ if (target == null) {
1003
+ return {
1004
+ ok: false,
1005
+ reason: `no application target named "${appName}"`,
1006
+ };
1007
+ }
1008
+ }
1009
+ const configUuids = targetBuildConfigUuids(text, target);
1010
+ if (configUuids.length === 0) {
1011
+ return {ok: false, reason: 'could not resolve target build configurations'};
1012
+ }
1013
+ if (configUuids.some(c => configUsesPods(text, c))) {
1014
+ return {
1015
+ ok: false,
1016
+ reason:
1017
+ 'target uses CocoaPods (Pods-*.xcconfig) — in-place injection only ' +
1018
+ 'supports SPM-only targets',
1019
+ };
1020
+ }
1021
+ // The target's own Frameworks build phase (where product build files link).
1022
+ const buildPhases = findField(text, target, 'buildPhases');
1023
+ const phaseUuids =
1024
+ buildPhases != null
1025
+ ? (buildPhases.value.match(/[0-9A-Fa-f]{24}/g) ?? [])
1026
+ : [];
1027
+ let frameworksPhaseUuid = null;
1028
+ // Also capture the Sources phase — plugin generated sources compile into it
1029
+ // (see injectSpmIntoPbxproj step 8). Nullable: a target may legitimately
1030
+ // lack one, in which case generated-source wiring is skipped (not fatal).
1031
+ let sourcesPhaseUuid = null;
1032
+ for (const pu of phaseUuids) {
1033
+ const po = findObjectByUuid(text, pu);
1034
+ if (po == null) {
1035
+ continue;
1036
+ }
1037
+ const isa = findField(text, po, 'isa');
1038
+ if (isa == null) {
1039
+ continue;
1040
+ }
1041
+ if (
1042
+ frameworksPhaseUuid == null &&
1043
+ /PBXFrameworksBuildPhase/.test(isa.value)
1044
+ ) {
1045
+ frameworksPhaseUuid = pu;
1046
+ } else if (
1047
+ sourcesPhaseUuid == null &&
1048
+ /PBXSourcesBuildPhase/.test(isa.value)
1049
+ ) {
1050
+ sourcesPhaseUuid = pu;
1051
+ }
1052
+ }
1053
+ if (frameworksPhaseUuid == null) {
1054
+ return {ok: false, reason: 'target has no Frameworks build phase'};
1055
+ }
1056
+ return {
1057
+ ok: true,
1058
+ rootUuid: project.uuid,
1059
+ target,
1060
+ configUuids,
1061
+ frameworksPhaseUuid,
1062
+ sourcesPhaseUuid,
1063
+ };
1064
+ }
1065
+
1066
+ /**
1067
+ * Splice the SPM dependency graph + React build settings + sync build phase
1068
+ * into `text` and return the modified pbxproj. Pure string transform (no I/O),
1069
+ * idempotent: objects already present (by UUID) and array members / settings
1070
+ * already applied are skipped, so a second run is a no-op.
1071
+ */
1072
+ function injectSpmIntoPbxproj(
1073
+ input /*: string */,
1074
+ plan /*: {rootUuid: string, targetUuid: string, configUuids: Array<string>, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */,
1075
+ reactNativePath /*: string */,
1076
+ remote /*: ?RemoteCfg */,
1077
+ hermesCliPath /*: ?string */ = null,
1078
+ generatedSources /*: ReadonlyArray<GeneratedSource> */ = [],
1079
+ flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
1080
+ ) /*: {text: string, injectedUuids: Array<string>, createdArrayFields: Array<{container: 'project' | 'target', key: string}>, buildSettingChanges: Array<BuildSettingChange>, generatedSourceUuids: {[string]: Array<string>}} */ {
1081
+ let text = input;
1082
+ const mkUuid = (section /*: string */, id /*: string */) =>
1083
+ namespacedUUID(plan.rootUuid, section, id);
1084
+ const graph = buildSpmDependencyGraph(mkUuid, remote);
1085
+ const entries = spmGraphToEntries(graph);
1086
+ const injectedUuids /*: Array<string> */ = [];
1087
+
1088
+ // 1. Insert the new objects (skip any UUID already present — idempotency).
1089
+ const insertObjects = (
1090
+ sectionName /*: string */,
1091
+ objs /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...}> */,
1092
+ ) => {
1093
+ const fresh = objs.filter(o => !text.includes(o.uuid));
1094
+ for (const o of objs) {
1095
+ injectedUuids.push(o.uuid);
1096
+ }
1097
+ if (fresh.length === 0) {
1098
+ return;
1099
+ }
1100
+ text = insertObjectsIntoSection(
1101
+ text,
1102
+ sectionName,
1103
+ fresh.map(serializeEntry).join('\n'),
1104
+ );
1105
+ };
1106
+ insertObjects('XCLocalSwiftPackageReference', entries.localRefs);
1107
+ if (entries.remoteRef != null) {
1108
+ insertObjects('XCRemoteSwiftPackageReference', [entries.remoteRef]);
1109
+ }
1110
+ insertObjects('XCSwiftPackageProductDependency', entries.productDeps);
1111
+ insertObjects('PBXBuildFile', entries.buildFiles);
1112
+
1113
+ // Track array fields we CREATE (vs. append to a pre-existing one) so deinit
1114
+ // can remove the whole field and land byte-identical to the original.
1115
+ const createdArrayFields /*: Array<{container: 'project' | 'target', key: string}> */ =
1116
+ [];
1117
+
1118
+ // 2. packageReferences on the PBXProject.
1119
+ const pkgRefMembers = [
1120
+ ...(graph.remotePkgRef != null
1121
+ ? [{uuid: graph.remotePkgRef.uuid, comment: graph.remotePkgRef.comment}]
1122
+ : []),
1123
+ ...graph.localPkgRefs.map(r => ({uuid: r.uuid, comment: r.comment})),
1124
+ ];
1125
+ const project = findProjectObject(text);
1126
+ if (project != null) {
1127
+ if (findField(text, project, 'packageReferences') == null) {
1128
+ createdArrayFields.push({container: 'project', key: 'packageReferences'});
1129
+ }
1130
+ text = addArrayMembers(text, project, 'packageReferences', pkgRefMembers);
1131
+ }
1132
+
1133
+ // 3. packageProductDependencies on the app target.
1134
+ const productMembers = graph.products.map(p => ({
1135
+ uuid: p.depUuid,
1136
+ comment: p.product,
1137
+ }));
1138
+ if (
1139
+ findField(
1140
+ text,
1141
+ findApplicationTargetByUuid(text, plan.targetUuid),
1142
+ 'packageProductDependencies',
1143
+ ) == null
1144
+ ) {
1145
+ createdArrayFields.push({
1146
+ container: 'target',
1147
+ key: 'packageProductDependencies',
1148
+ });
1149
+ }
1150
+ text = addArrayMembers(
1151
+ text,
1152
+ findApplicationTargetByUuid(text, plan.targetUuid),
1153
+ 'packageProductDependencies',
1154
+ productMembers,
1155
+ );
1156
+
1157
+ // 4. product build files into the target's Frameworks phase.
1158
+ const phase = findObjectByUuid(text, plan.frameworksPhaseUuid);
1159
+ if (phase != null) {
1160
+ text = addArrayMembers(
1161
+ text,
1162
+ phase,
1163
+ 'files',
1164
+ graph.products.map(p => ({
1165
+ uuid: p.buildFileUuid,
1166
+ comment: `${p.product} in Frameworks`,
1167
+ })),
1168
+ );
1169
+ }
1170
+
1171
+ // 5. React build settings into every build config (Debug + Release).
1172
+ const buildSettingChanges /*: Array<BuildSettingChange> */ = [];
1173
+ for (const configUuid of plan.configUuids) {
1174
+ const merged = mergeReactBuildSettings(
1175
+ text,
1176
+ configUuid,
1177
+ buildConfigurationName(text, configUuid),
1178
+ reactNativePath,
1179
+ hermesCliPath,
1180
+ flavoredFrameworks,
1181
+ );
1182
+ text = merged.text;
1183
+ buildSettingChanges.push(merged.change);
1184
+ }
1185
+
1186
+ // 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action
1187
+ // is what fires before SPM resolution). Prepended so it runs before
1188
+ // Sources. We do NOT add a JS-bundle phase — an existing app already
1189
+ // bundles JS via its own phase.
1190
+ const syncScript = buildSyncAutolinkingScript(reactNativePath);
1191
+ const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking');
1192
+ if (!text.includes(syncPhaseUuid)) {
1193
+ text = insertObjectsIntoSection(
1194
+ text,
1195
+ 'PBXShellScriptBuildPhase',
1196
+ serializeEntry(
1197
+ shellScriptPhase(syncPhaseUuid, 'Sync SPM Autolinking', syncScript),
1198
+ ),
1199
+ );
1200
+ } else {
1201
+ // Already injected on a prior run — the phase object owns its
1202
+ // shellScript, so refresh it in place (same quoting used at creation) in
1203
+ // case the generated script changed since. Byte-identical when it
1204
+ // didn't; field order and every other byte of the phase are untouched.
1205
+ const existingPhase = findObjectByUuid(text, syncPhaseUuid);
1206
+ if (existingPhase != null) {
1207
+ text = setScalarField(
1208
+ text,
1209
+ existingPhase,
1210
+ 'shellScript',
1211
+ quoteIfNeeded(syncScript),
1212
+ );
1213
+ }
1214
+ }
1215
+ injectedUuids.push(syncPhaseUuid);
1216
+ text = addArrayMembers(
1217
+ text,
1218
+ findApplicationTargetByUuid(text, plan.targetUuid),
1219
+ 'buildPhases',
1220
+ [{uuid: syncPhaseUuid, comment: 'Sync SPM Autolinking'}],
1221
+ {prepend: true},
1222
+ );
1223
+
1224
+ // 7. The sole writer of flavored frameworks under the final app bundle.
1225
+ // SwiftPM owns only invariant header/source products, so no implicit SPM
1226
+ // embed task competes with this phase.
1227
+ const embedPhaseUuid = mkUuid(
1228
+ 'PBXShellScriptBuildPhase',
1229
+ 'EmbedFlavoredFrameworks',
1230
+ );
1231
+ const embedScript = buildEmbedFrameworksScript(flavoredFrameworks);
1232
+ const embedInputs = [
1233
+ '$(SRCROOT)/build/xcframeworks/.artifact-stamp',
1234
+ ...flavoredFrameworks.map(
1235
+ framework => `$(${frameworkSettingPrefix(framework.id)}_FRAMEWORK)`,
1236
+ ),
1237
+ ];
1238
+ const embedOutputs = flavoredFrameworks.map(
1239
+ framework =>
1240
+ `$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/${framework.frameworkName}.framework`,
1241
+ );
1242
+ const embedEntry = shellScriptPhase(
1243
+ embedPhaseUuid,
1244
+ 'Embed React Native Flavored Frameworks',
1245
+ embedScript,
1246
+ {
1247
+ inputPaths: pbxPathList(embedInputs),
1248
+ outputPaths: pbxPathList(embedOutputs),
1249
+ },
1250
+ );
1251
+ if (!text.includes(embedPhaseUuid)) {
1252
+ text = insertObjectsIntoSection(
1253
+ text,
1254
+ 'PBXShellScriptBuildPhase',
1255
+ serializeEntry(embedEntry),
1256
+ );
1257
+ } else {
1258
+ const existingPhase = findObjectByUuid(text, embedPhaseUuid);
1259
+ if (existingPhase != null) {
1260
+ for (const key of ['shellScript', 'inputPaths', 'outputPaths']) {
1261
+ const current = findObjectByUuid(text, embedPhaseUuid);
1262
+ if (current != null) {
1263
+ text = setScalarField(text, current, key, embedEntry.fields[key]);
1264
+ }
1265
+ }
1266
+ }
1267
+ }
1268
+ injectedUuids.push(embedPhaseUuid);
1269
+ text = addBuildPhaseAfter(
1270
+ text,
1271
+ findApplicationTargetByUuid(text, plan.targetUuid),
1272
+ plan.frameworksPhaseUuid,
1273
+ {
1274
+ uuid: embedPhaseUuid,
1275
+ comment: 'Embed React Native Flavored Frameworks',
1276
+ },
1277
+ );
1278
+
1279
+ // 8. Plugin generated sources compiled INTO THE APP TARGET (e.g. Expo's
1280
+ // ExpoModulesProvider.swift). An `@objc` class only reaches the ObjC
1281
+ // classlist — required for NSClassFromString discovery — when it compiles
1282
+ // into the app target, not the static Autolinked aggregate. Each source
1283
+ // gets a PBXFileReference + PBXBuildFile + a Sources-phase entry, parented
1284
+ // under a single "SPM Generated Sources" group. Every UUID is keyed on the
1285
+ // normalized path (deterministic → idempotent) and recorded so `deinit`
1286
+ // reverts it and `update` reconciles it (removal is done by the caller,
1287
+ // which owns the prior marker; emission here is purely additive).
1288
+ const generatedSourceUuids /*: {[string]: Array<string>} */ = {};
1289
+ const sourcesPhaseUuid = plan.sourcesPhaseUuid;
1290
+ if (generatedSources.length > 0) {
1291
+ if (sourcesPhaseUuid == null) {
1292
+ log(
1293
+ 'warning: the app target has no Sources build phase — cannot compile ' +
1294
+ `${generatedSources.length} SPM plugin generated source(s) into the ` +
1295
+ 'app target; skipping. Any @objc classes they define will not be ' +
1296
+ 'discoverable via NSClassFromString.',
1297
+ );
1298
+ } else {
1299
+ const fileRefs = [];
1300
+ const buildFiles = [];
1301
+ const sourcesMembers = [];
1302
+ const groupChildren = [];
1303
+ for (const src of generatedSources) {
1304
+ const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`);
1305
+ const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`);
1306
+ generatedSourceUuids[src.path] = [fileRefUuid, buildFileUuid];
1307
+ fileRefs.push({
1308
+ uuid: fileRefUuid,
1309
+ comment: src.name,
1310
+ fields: {
1311
+ isa: 'PBXFileReference',
1312
+ lastKnownFileType: src.fileType,
1313
+ name: quoteIfNeeded(src.name),
1314
+ path: quoteIfNeeded(src.path),
1315
+ sourceTree: src.sourceTree,
1316
+ },
1317
+ });
1318
+ buildFiles.push({
1319
+ uuid: buildFileUuid,
1320
+ comment: `${src.name} in Sources`,
1321
+ fields: {
1322
+ isa: 'PBXBuildFile',
1323
+ fileRef: `${fileRefUuid} /* ${src.name} */`,
1324
+ },
1325
+ });
1326
+ sourcesMembers.push({
1327
+ uuid: buildFileUuid,
1328
+ comment: `${src.name} in Sources`,
1329
+ });
1330
+ groupChildren.push({uuid: fileRefUuid, comment: src.name});
1331
+ }
1332
+ insertObjects('PBXFileReference', fileRefs);
1333
+ insertObjects('PBXBuildFile', buildFiles);
1334
+
1335
+ // Compile membership — the actual reason these are wired into the app.
1336
+ const sourcesPhase = findObjectByUuid(text, sourcesPhaseUuid);
1337
+ if (sourcesPhase != null) {
1338
+ text = addArrayMembers(text, sourcesPhase, 'files', sourcesMembers);
1339
+ }
1340
+
1341
+ // The "SPM Generated Sources" group (created on first use, then reused).
1342
+ // Insert with empty children so the ONE population path (addArrayMembers)
1343
+ // handles both create and reconcile, keeping formatting identical.
1344
+ const groupUuid = mkUuid('PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID);
1345
+ if (!text.includes(groupUuid)) {
1346
+ text = insertObjectsIntoSection(
1347
+ text,
1348
+ 'PBXGroup',
1349
+ serializeEntry({
1350
+ uuid: groupUuid,
1351
+ comment: SPM_GENERATED_SOURCES_GROUP_NAME,
1352
+ fields: {
1353
+ isa: 'PBXGroup',
1354
+ children: '(\n\t\t\t)',
1355
+ name: quoteIfNeeded(SPM_GENERATED_SOURCES_GROUP_NAME),
1356
+ sourceTree: '"<group>"',
1357
+ },
1358
+ }),
1359
+ );
1360
+ }
1361
+ injectedUuids.push(groupUuid);
1362
+ const groupObj = findObjectByUuid(text, groupUuid);
1363
+ if (groupObj != null) {
1364
+ text = addArrayMembers(text, groupObj, 'children', groupChildren);
1365
+ }
1366
+
1367
+ // Parent the group under the project's main group (idempotent). Appends
1368
+ // to a pre-existing children array, so no createdArrayField is recorded —
1369
+ // deinit removes the group's membership via removeArrayMembersByUuid and
1370
+ // the group object itself via removeObjectByUuid (groupUuid is injected).
1371
+ const proj = findProjectObject(text);
1372
+ const mainGroupField =
1373
+ proj != null ? findField(text, proj, 'mainGroup') : null;
1374
+ const mainGroupMatch =
1375
+ mainGroupField != null
1376
+ ? mainGroupField.value.match(/[0-9A-Fa-f]{24}/)
1377
+ : null;
1378
+ const mainGroupObj =
1379
+ mainGroupMatch != null
1380
+ ? findObjectByUuid(text, mainGroupMatch[0])
1381
+ : null;
1382
+ if (mainGroupObj != null) {
1383
+ text = addArrayMembers(text, mainGroupObj, 'children', [
1384
+ {uuid: groupUuid, comment: SPM_GENERATED_SOURCES_GROUP_NAME},
1385
+ ]);
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ return {
1391
+ text,
1392
+ injectedUuids,
1393
+ createdArrayFields,
1394
+ buildSettingChanges,
1395
+ generatedSourceUuids,
1396
+ };
1397
+ }
1398
+
1399
+ /** Re-locate an application target by UUID against the current text. */
1400
+ function findApplicationTargetByUuid(
1401
+ text /*: string */,
1402
+ targetUuid /*: string */,
1403
+ ) /*: {uuid: string, bodyOpen: number, bodyClose: number} */ {
1404
+ const obj = findObjectByUuid(text, targetUuid);
1405
+ if (obj == null) {
1406
+ throw new Error(`pbxproj: app target ${targetUuid} disappeared mid-edit`);
1407
+ }
1408
+ return obj;
1409
+ }
1410
+
1411
+ /**
1412
+ * Merge the React build settings into one XCBuildConfiguration's dict. Returns
1413
+ * the modified text plus a precise record of what was actually added — so
1414
+ * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching
1415
+ * a value the user already had (key insight: ensureScalarField/
1416
+ * addArrayStringValues are no-ops / dedupe when a value is already present).
1417
+ */
1418
+ /**
1419
+ * Resolves the host `hermesc` from the `hermes-compiler` npm package and returns
1420
+ * its ABSOLUTE path as the HERMES_CLI_PATH value, or null when it can't be found
1421
+ * (e.g. USE_HERMES=false apps without the package). require.resolve (anchored at
1422
+ * reactNativeRoot) follows Node's lookup, so a hoisted monorepo layout — where
1423
+ * hermes-compiler sits in the workspace-root node_modules, NOT next to
1424
+ * react-native — resolves correctly.
1425
+ *
1426
+ * The value is intentionally ABSOLUTE, not `$(REACT_NATIVE_PATH)/../...`: when
1427
+ * react-native is a symlink (the monorepo default, and common in real apps), a
1428
+ * `..` after it resolves — kernel-side — to the symlink TARGET's parent, not the
1429
+ * node_modules dir, so the relative form points at a non-existent
1430
+ * `<rn-target>/../hermes-compiler`. An absolute path sidesteps that entirely
1431
+ * (and matches how the CocoaPods hermes-engine pod sets HERMES_CLI_PATH). It is
1432
+ * regenerated on every `spm add`, so machine-specificity is a non-issue.
1433
+ */
1434
+ function resolveHermesCliPathSetting(
1435
+ reactNativeRoot /*: string */,
1436
+ ) /*: ?string */ {
1437
+ try {
1438
+ const pkg = require.resolve('hermes-compiler/package.json', {
1439
+ paths: [reactNativeRoot],
1440
+ });
1441
+ const hermesc = path.join(
1442
+ path.dirname(pkg),
1443
+ 'hermesc',
1444
+ 'osx-bin',
1445
+ 'hermesc',
1446
+ );
1447
+ return fs.existsSync(hermesc) ? hermesc : null;
1448
+ } catch {
1449
+ return null;
1450
+ }
1451
+ }
1452
+
1453
+ function mergeReactBuildSettings(
1454
+ input /*: string */,
1455
+ configUuid /*: string */,
1456
+ configurationName /*: string */,
1457
+ reactNativePath /*: string */,
1458
+ hermesCliPath /*: ?string */ = null,
1459
+ flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
1460
+ ) /*: {text: string, change: BuildSettingChange} */ {
1461
+ let text = input;
1462
+ const scalars = [
1463
+ {key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'},
1464
+ {key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)},
1465
+ // Under SwiftPM there is no hermes-engine pod, so react-native-xcode.sh's
1466
+ // fallback ($PODS_ROOT/hermes-engine/destroot/bin/hermesc) resolves to a
1467
+ // non-existent "/hermes-engine/..." and the Release JS→Hermes bundling
1468
+ // fails. Point HERMES_CLI_PATH at the hermes-compiler npm package's host
1469
+ // hermesc (an ABSOLUTE path resolved by the caller — see
1470
+ // resolveHermesCliPathSetting). react-native-xcode.sh honors an already-set
1471
+ // HERMES_CLI_PATH before its pod fallback; ensureScalarField leaves any
1472
+ // user-provided value untouched.
1473
+ ...(hermesCliPath != null
1474
+ ? [{key: 'HERMES_CLI_PATH', value: quoteIfNeeded(hermesCliPath)}]
1475
+ : []),
1476
+ ];
1477
+ // Re-locate the buildSettings dict before each edit (offsets shift).
1478
+ const dict = () => {
1479
+ const cfg = findObjectByUuid(text, configUuid);
1480
+ if (cfg == null) {
1481
+ return null;
1482
+ }
1483
+ const bs = findField(text, cfg, 'buildSettings');
1484
+ if (bs == null) {
1485
+ return null;
1486
+ }
1487
+ return {
1488
+ uuid: configUuid,
1489
+ bodyOpen: bs.valueStart,
1490
+ bodyClose: bs.tokenEnd - 1,
1491
+ };
1492
+ };
1493
+ const createdArrayKeys /*: Array<string> */ = [];
1494
+ const appendedArrayValues /*: {[string]: Array<string>} */ = {};
1495
+ const createdScalars /*: Array<string> */ = [];
1496
+ const arraySettings = [
1497
+ ...INJECTED_ARRAY_SETTINGS,
1498
+ ...frameworkArrayBuildSettings(flavoredFrameworks),
1499
+ ];
1500
+ for (const {key, values} of arraySettings) {
1501
+ const d = dict();
1502
+ if (d == null) {
1503
+ continue;
1504
+ }
1505
+ const existing = findField(text, d, key);
1506
+ if (existing == null) {
1507
+ createdArrayKeys.push(key);
1508
+ } else {
1509
+ const fresh = values.filter(v => !existing.value.includes(v));
1510
+ if (fresh.length > 0) {
1511
+ appendedArrayValues[key] = fresh;
1512
+ }
1513
+ }
1514
+ text = addArrayStringValues(text, d, key, values);
1515
+ }
1516
+ const replacedScalars /*: {[string]: string} */ = {};
1517
+ for (const {key, value} of scalars) {
1518
+ const d = dict();
1519
+ if (d == null) {
1520
+ continue;
1521
+ }
1522
+ const existing = findField(text, d, key);
1523
+ if (existing == null) {
1524
+ createdScalars.push(key);
1525
+ } else if (
1526
+ key === 'REACT_NATIVE_PATH' &&
1527
+ existing.value.includes('PODS_ROOT')
1528
+ ) {
1529
+ // A ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template
1530
+ // default) dangles once CocoaPods is deintegrated: PODS_ROOT resolves
1531
+ // empty at build time, so the Bundle React Native code and images
1532
+ // phase looks for "/../…/scripts/xcode/with-environment.sh". Replace
1533
+ // it with the SPM-computed path, recording the original for deinit.
1534
+ replacedScalars[key] = existing.value;
1535
+ text = removeField(text, d, key);
1536
+ const d2 = dict();
1537
+ if (d2 == null) {
1538
+ continue;
1539
+ }
1540
+ text = ensureScalarField(text, d2, key, value);
1541
+ continue;
1542
+ }
1543
+ text = ensureScalarField(text, d, key, value);
1544
+ }
1545
+ const ownedScalars = [
1546
+ {
1547
+ key: 'RN_SPM_FLAVOR',
1548
+ value: flavorForBuildConfiguration(configurationName),
1549
+ },
1550
+ ...frameworkConditionalSettings(flavoredFrameworks),
1551
+ ];
1552
+ for (const {key, value} of ownedScalars) {
1553
+ const d = dict();
1554
+ if (d == null) {
1555
+ continue;
1556
+ }
1557
+ const existing = findField(text, d, key);
1558
+ if (existing == null) {
1559
+ createdScalars.push(key);
1560
+ } else if (existing.value !== value) {
1561
+ replacedScalars[key] = existing.value;
1562
+ }
1563
+ text = setScalarField(text, d, key, value);
1564
+ }
1565
+ return {
1566
+ text,
1567
+ change: {
1568
+ configUuid,
1569
+ createdArrayKeys,
1570
+ appendedArrayValues,
1571
+ createdScalars,
1572
+ replacedScalars,
1573
+ },
1574
+ };
1575
+ }
1576
+
1577
+ // Write only when content changed (avoids spurious Xcode reloads / git churn).
1578
+ function writeIfChanged(
1579
+ filePath /*: string */,
1580
+ content /*: string */,
1581
+ ) /*: boolean */ {
1582
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
1583
+ try {
1584
+ if (fs.readFileSync(filePath, 'utf8') === content) {
1585
+ return false;
1586
+ }
1587
+ } catch {
1588
+ /* file doesn't exist yet */
1589
+ }
1590
+ fs.writeFileSync(filePath, content, 'utf8');
1591
+ return true;
1592
+ }
1593
+
1594
+ /**
1595
+ * Add the "Sync SPM Autolinking" pre-action to an existing scheme's
1596
+ * BuildAction, reusing the scheme's own primary BuildableReference. Returns
1597
+ * the XML unchanged when the pre-action is already present.
1598
+ */
1599
+ function addPreActionToScheme(
1600
+ xml /*: string */,
1601
+ targetUuid /*: string */,
1602
+ syncScript /*: string */,
1603
+ ) /*: string */ {
1604
+ const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"');
1605
+ if (titleIdx >= 0) {
1606
+ // Already injected on a prior run — refresh a possibly-stale scriptText
1607
+ // in place (same escaping used at creation) rather than leaving it
1608
+ // forever. Splice by index (not a regex/string replace) since the script
1609
+ // itself may contain `$`-sequences that String.replace's replacement-
1610
+ // pattern syntax would otherwise misinterpret. Byte-identical when the
1611
+ // script is unchanged; every other byte of the scheme is untouched.
1612
+ const scriptTextMarker = 'scriptText = "';
1613
+ const stIdx = xml.indexOf(scriptTextMarker, titleIdx);
1614
+ if (stIdx < 0) {
1615
+ return xml; // malformed — leave untouched rather than guess
1616
+ }
1617
+ const valueStart = stIdx + scriptTextMarker.length;
1618
+ // escapeXmlAttribute maps a literal `"` to `&quot;`, so the attribute
1619
+ // value itself never contains one — the next `"` is always the closing
1620
+ // delimiter.
1621
+ const valueEnd = xml.indexOf('"', valueStart);
1622
+ return (
1623
+ xml.slice(0, valueStart) +
1624
+ escapeXmlAttribute(syncScript) +
1625
+ xml.slice(valueEnd)
1626
+ );
1627
+ }
1628
+ const refMatch = xml.match(
1629
+ new RegExp(
1630
+ `<BuildableReference\\b[^>]*BlueprintIdentifier = "${targetUuid}"[^>]*>`,
1631
+ ),
1632
+ );
1633
+ const attr = (name /*: string */) => {
1634
+ const m =
1635
+ refMatch != null
1636
+ ? refMatch[0].match(new RegExp(`${name} = "([^"]*)"`))
1637
+ : null;
1638
+ return m != null ? m[1] : '';
1639
+ };
1640
+ const cleanRef =
1641
+ `<BuildableReference\n` +
1642
+ ` BuildableIdentifier = "primary"\n` +
1643
+ ` BlueprintIdentifier = "${targetUuid}"\n` +
1644
+ ` BuildableName = "${attr('BuildableName')}"\n` +
1645
+ ` BlueprintName = "${attr('BlueprintName')}"\n` +
1646
+ ` ReferencedContainer = "${attr('ReferencedContainer')}">\n` +
1647
+ ` </BuildableReference>`;
1648
+ const executionAction =
1649
+ ` <ExecutionAction\n` +
1650
+ ` ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">\n` +
1651
+ ` <ActionContent\n` +
1652
+ ` title = "Sync SPM Autolinking"\n` +
1653
+ ` scriptText = "${escapeXmlAttribute(syncScript)}">\n` +
1654
+ ` <EnvironmentBuildable>\n` +
1655
+ ` ${cleanRef}\n` +
1656
+ ` </EnvironmentBuildable>\n` +
1657
+ ` </ActionContent>\n` +
1658
+ ` </ExecutionAction>`;
1659
+
1660
+ if (/<PreActions>/.test(xml)) {
1661
+ return xml.replace(
1662
+ '</PreActions>',
1663
+ `${executionAction}\n </PreActions>`,
1664
+ );
1665
+ }
1666
+ const openEnd = xml.indexOf('>', xml.indexOf('<BuildAction'));
1667
+ if (openEnd < 0) {
1668
+ return xml; // no BuildAction — leave the scheme untouched
1669
+ }
1670
+ const block = `\n <PreActions>\n${executionAction}\n </PreActions>`;
1671
+ return xml.slice(0, openEnd + 1) + block + xml.slice(openEnd + 1);
1672
+ }
1673
+
1674
+ /**
1675
+ * Ensure the app target's shared scheme runs the sync pre-action before SPM
1676
+ * resolution. Updates the scheme that builds the target if one exists,
1677
+ * otherwise creates a fresh shared scheme. Returns 'updated' | 'created' |
1678
+ * 'unchanged'.
1679
+ */
1680
+ function injectOrCreateScheme(
1681
+ xcodeprojDir /*: string */,
1682
+ opts /*: {appName: string, targetUuid: string, projName: string, syncScript: string} */,
1683
+ ) /*: {status: 'updated' | 'unchanged' | 'created', file: string} */ {
1684
+ const schemesDir = path.join(xcodeprojDir, 'xcshareddata', 'xcschemes');
1685
+ let schemeFiles /*: Array<string> */ = [];
1686
+ try {
1687
+ schemeFiles = fs
1688
+ .readdirSync(schemesDir)
1689
+ .filter(f => f.endsWith('.xcscheme'));
1690
+ } catch {
1691
+ /* no shared schemes dir yet */
1692
+ }
1693
+ for (const f of schemeFiles) {
1694
+ const p = path.join(schemesDir, f);
1695
+ const xml = fs.readFileSync(p, 'utf8');
1696
+ if (xml.includes(`BlueprintIdentifier = "${opts.targetUuid}"`)) {
1697
+ const updated = addPreActionToScheme(
1698
+ xml,
1699
+ opts.targetUuid,
1700
+ opts.syncScript,
1701
+ );
1702
+ return {
1703
+ status: writeIfChanged(p, updated) ? 'updated' : 'unchanged',
1704
+ file: f,
1705
+ };
1706
+ }
1707
+ }
1708
+ const file = `${opts.appName}.xcscheme`;
1709
+ const xml = generateXcscheme(
1710
+ opts.appName,
1711
+ opts.targetUuid,
1712
+ opts.projName,
1713
+ opts.syncScript,
1714
+ );
1715
+ writeIfChanged(path.join(schemesDir, file), xml);
1716
+ return {status: 'created', file};
1717
+ }
1718
+
1719
+ /**
1720
+ * Strip the empty `Pods` group `pod deintegrate` leaves in the navigator.
1721
+ * Called by `add --deintegrate` after deintegration so the converted project is
1722
+ * visually clean. No-op when absent or when the group still has children.
1723
+ */
1724
+ function cleanupLeftoverPodsGroup(xcodeprojPath /*: string */) /*: boolean */ {
1725
+ const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
1726
+ if (!fs.existsSync(pbxprojPath)) {
1727
+ return false;
1728
+ }
1729
+ const original = fs.readFileSync(pbxprojPath, 'utf8');
1730
+ const cleaned = removeEmptyPodsGroup(original);
1731
+ return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false;
1732
+ }
1733
+
1734
+ /**
1735
+ * Strip the dangling `JavaScriptCore.framework` file reference the community
1736
+ * template has carried since RN 0.60 (navigator-only, meaningless under
1737
+ * Hermes) — see `removeDanglingJavaScriptCoreRef` for the full rationale and
1738
+ * the safety gate that leaves a still-linked reference untouched. No-op when
1739
+ * absent or when the pbxproj is missing.
1740
+ */
1741
+ function cleanupDanglingJavaScriptCoreRef(
1742
+ xcodeprojPath /*: string */,
1743
+ ) /*: boolean */ {
1744
+ const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
1745
+ if (!fs.existsSync(pbxprojPath)) {
1746
+ return false;
1747
+ }
1748
+ const original = fs.readFileSync(pbxprojPath, 'utf8');
1749
+ const cleaned = removeDanglingJavaScriptCoreRef(original);
1750
+ return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false;
1751
+ }
1752
+
1753
+ /**
1754
+ * Normalize one plugin generated-source path into the fields a PBXFileReference
1755
+ * needs. Stores an SRCROOT-relative path (`sourceTree = SOURCE_ROOT`) when the
1756
+ * source lives under the app root — the typical case (build/generated/…) — and
1757
+ * an absolute path (`sourceTree = "<absolute>"`) otherwise. Returns null (with a
1758
+ * loud log) for an extension the pbxproj can't compile.
1759
+ */
1760
+ function normalizeGeneratedSource(
1761
+ appRoot /*: string */,
1762
+ srcPath /*: string */,
1763
+ ) /*: ?GeneratedSource */ {
1764
+ const ext = path.extname(srcPath).toLowerCase();
1765
+ const fileType = GENERATED_SOURCE_FILE_TYPES[ext];
1766
+ if (fileType == null) {
1767
+ log(
1768
+ `warning: unsupported generated-source extension "${ext}" for ` +
1769
+ `${srcPath}; skipping (SPM plugin sources must be .swift/.m/.mm).`,
1770
+ );
1771
+ return null;
1772
+ }
1773
+ const abs = path.isAbsolute(srcPath)
1774
+ ? srcPath
1775
+ : path.resolve(appRoot, srcPath);
1776
+ const rel = path.relative(appRoot, abs);
1777
+ const underAppRoot =
1778
+ rel !== '' &&
1779
+ rel !== '..' &&
1780
+ !rel.startsWith('..' + path.sep) &&
1781
+ !path.isAbsolute(rel);
1782
+ return {
1783
+ path: underAppRoot ? rel : abs,
1784
+ name: path.basename(abs),
1785
+ sourceTree: underAppRoot ? 'SOURCE_ROOT' : '"<absolute>"',
1786
+ fileType,
1787
+ };
1788
+ }
1789
+
1790
+ /**
1791
+ * Read + normalize the plugin generated-sources manifest at
1792
+ * `<appRoot>/build/generated/autolinking/.spm-plugin-generated-sources.json`.
1793
+ * Absent, empty, or malformed → `[]` (the feature stays inert for non-plugin
1794
+ * apps and never breaks injection). The file need not exist yet at inject time:
1795
+ * the build-time sync regenerates it before compile, and a PBXFileReference to a
1796
+ * not-yet-created path is valid.
1797
+ */
1798
+ function readGeneratedSourcesManifest(
1799
+ appRoot /*: string */,
1800
+ ) /*: Array<GeneratedSource> */ {
1801
+ const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST);
1802
+ let raw: string;
1803
+ try {
1804
+ raw = fs.readFileSync(manifestPath, 'utf8');
1805
+ } catch {
1806
+ return [];
1807
+ }
1808
+ let entries: unknown;
1809
+ try {
1810
+ entries = JSON.parse(raw);
1811
+ } catch {
1812
+ log(
1813
+ `warning: could not parse ${SPM_GENERATED_SOURCES_MANIFEST}; ` +
1814
+ 'skipping generated sources.',
1815
+ );
1816
+ return [];
1817
+ }
1818
+ if (!Array.isArray(entries)) {
1819
+ return [];
1820
+ }
1821
+ const out /*: Array<GeneratedSource> */ = [];
1822
+ for (const entry of entries) {
1823
+ if (
1824
+ entry == null ||
1825
+ typeof entry !== 'object' ||
1826
+ typeof entry.path !== 'string'
1827
+ ) {
1828
+ continue;
1829
+ }
1830
+ const normalized = normalizeGeneratedSource(appRoot, entry.path);
1831
+ // Dedupe by normalized path — a duplicate manifest entry would otherwise
1832
+ // double-insert identical-UUID pbxproj objects.
1833
+ if (normalized != null && !out.some(s => s.path === normalized.path)) {
1834
+ out.push(normalized);
1835
+ }
1836
+ }
1837
+ return out;
1838
+ }
1839
+
1840
+ /**
1841
+ * Read the `.spm-injected.json` marker of a previously-injected project, or
1842
+ * null when absent/unreadable. Used to reconcile generated sources on `update`
1843
+ * and to read back a pinned `artifactsVersionOverride` (see below).
1844
+ */
1845
+ function readMarker(
1846
+ xcodeprojPath /*: string */,
1847
+ ) /*: ?{generatedSources?: {[string]: Array<string>}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array<BuildSettingChange>, ...} */ {
1848
+ const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
1849
+ try {
1850
+ // $FlowFixMe[incompatible-return] JSON.parse returns any
1851
+ return JSON.parse(fs.readFileSync(markerPath, 'utf8'));
1852
+ } catch {
1853
+ return null;
1854
+ }
1855
+ }
1856
+
1857
+ // Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json`
1858
+ // marker (the user-owned project SPM packages were injected into in place),
1859
+ // or null when none has been injected yet. Pure fs reads — safe for the
1860
+ // build-time sync (sync-spm-autolinking.js, via readArtifactsVersionOverride
1861
+ // below) to call without pulling in any pbxproj-editing machinery at runtime.
1862
+ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
1863
+ let entries: Array<{name: string, isDirectory(): boolean}>;
1864
+ try {
1865
+ // $FlowFixMe[incompatible-type] Dirent typing
1866
+ entries = fs.readdirSync(appRoot, {withFileTypes: true});
1867
+ } catch {
1868
+ return null;
1869
+ }
1870
+ for (const entry of entries) {
1871
+ if (!entry.isDirectory()) continue;
1872
+ // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs
1873
+ const name /*: string */ = entry.name;
1874
+ if (!name.endsWith('.xcodeproj')) continue;
1875
+ if (fs.existsSync(path.join(appRoot, name, SPM_INJECTED_MARKER))) {
1876
+ return path.join(appRoot, name);
1877
+ }
1878
+ }
1879
+ return null;
1880
+ }
1881
+
1882
+ /**
1883
+ * Read the `artifactsVersionOverride` a previous `spm add --version` / `spm
1884
+ * update --version` pinned into the injected xcodeproj's `.spm-injected.json`
1885
+ * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
1886
+ * below), or null when no project is injected yet, no override is pinned, or
1887
+ * the marker can't be read (never throws). Pure fs reads — the build-time
1888
+ * sync (sync-spm-autolinking.js) calls this to prefer the pinned version over
1889
+ * the one derived from node_modules/react-native/package.json, so a
1890
+ * version-mismatched setup keeps healing against the SAME artifact slot the
1891
+ * explicit `--version` selected.
1892
+ */
1893
+ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
1894
+ const xcodeprojPath = findInjectedXcodeproj(appRoot);
1895
+ if (xcodeprojPath == null) {
1896
+ return null;
1897
+ }
1898
+ const override = readMarker(xcodeprojPath)?.artifactsVersionOverride;
1899
+ return typeof override === 'string' && override.length > 0 ? override : null;
1900
+ }
1901
+
1902
+ /**
1903
+ * Add SPM packages to a user's EXISTING xcodeproj in place. Returns
1904
+ * {status: 'injected', target} on success, or {status: 'refused', reason}
1905
+ * when the project can't be safely edited (caller surfaces it; fail-loud).
1906
+ */
1907
+ function injectSpmIntoExistingXcodeproj(
1908
+ opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string} */,
1909
+ ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ {
1910
+ const {appRoot, reactNativeRoot, xcodeprojPath} = opts;
1911
+ const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
1912
+ if (!fs.existsSync(pbxprojPath)) {
1913
+ return {
1914
+ status: 'refused',
1915
+ reason: `no project.pbxproj at ${xcodeprojPath}`,
1916
+ };
1917
+ }
1918
+ const original = fs.readFileSync(pbxprojPath, 'utf8');
1919
+ const plan = planInjection(original, {appName: opts.appName});
1920
+ if (!plan.ok) {
1921
+ return {status: 'refused', reason: plan.reason};
1922
+ }
1923
+ const reactNativePath = path.relative(appRoot, reactNativeRoot);
1924
+ const remote = remotePackageConfig(appRoot);
1925
+ const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot);
1926
+ const generatedSources = readGeneratedSourcesManifest(appRoot);
1927
+ const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks;
1928
+
1929
+ const prevMarker = readMarker(xcodeprojPath);
1930
+
1931
+ // Reconcile generated sources injected on a PRIOR run that are no longer in
1932
+ // the manifest (a plugin's entry was dropped, or the plugin was removed).
1933
+ // Diff the marker's `generatedSources` map against the current manifest and
1934
+ // delete only the stale UUIDs — the additive injection below re-emits (and
1935
+ // idempotently skips) everything that remains, so an unchanged run stays
1936
+ // byte-identical. deinit needs none of this: the removed objects live in
1937
+ // `injectedUuids`.
1938
+ const prevGeneratedSources /*: {[string]: Array<string>} */ =
1939
+ prevMarker?.generatedSources ?? {};
1940
+ const currentPaths = new Set(generatedSources.map(s => s.path));
1941
+ const staleUuids /*: Array<string> */ = [];
1942
+ for (const p of Object.keys(prevGeneratedSources)) {
1943
+ if (!currentPaths.has(p)) {
1944
+ staleUuids.push(...prevGeneratedSources[p]);
1945
+ }
1946
+ }
1947
+ // When the last generated source is gone, retire the now-empty group too.
1948
+ if (
1949
+ generatedSources.length === 0 &&
1950
+ Object.keys(prevGeneratedSources).length > 0
1951
+ ) {
1952
+ staleUuids.push(
1953
+ namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID),
1954
+ );
1955
+ }
1956
+ // Re-apply generated settings from a clean recorded baseline. This removes
1957
+ // linker entries for plugin frameworks that disappeared and keeps the new
1958
+ // marker a complete inverse after an idempotent update.
1959
+ let base = removeRecordedBuildSettings(
1960
+ original,
1961
+ prevMarker?.buildSettingChanges ?? [],
1962
+ );
1963
+ if (staleUuids.length > 0) {
1964
+ base = removeArrayMembersByUuid(base, staleUuids);
1965
+ for (const u of staleUuids) {
1966
+ base = removeObjectByUuid(base, u);
1967
+ }
1968
+ }
1969
+
1970
+ const {
1971
+ text,
1972
+ injectedUuids,
1973
+ createdArrayFields,
1974
+ buildSettingChanges,
1975
+ generatedSourceUuids,
1976
+ } = injectSpmIntoPbxproj(
1977
+ base,
1978
+ {
1979
+ rootUuid: plan.rootUuid,
1980
+ targetUuid: plan.target.uuid,
1981
+ configUuids: plan.configUuids,
1982
+ frameworksPhaseUuid: plan.frameworksPhaseUuid,
1983
+ sourcesPhaseUuid: plan.sourcesPhaseUuid,
1984
+ },
1985
+ reactNativePath,
1986
+ remote,
1987
+ hermesCliPath,
1988
+ generatedSources,
1989
+ flavoredFrameworks,
1990
+ );
1991
+
1992
+ const changed = writeIfChanged(pbxprojPath, text);
1993
+ log(
1994
+ changed
1995
+ ? `Injected SPM packages into ${path.relative(appRoot, pbxprojPath)}`
1996
+ : `${path.relative(appRoot, pbxprojPath)} already up to date`,
1997
+ );
1998
+
1999
+ const projName = path.basename(xcodeprojPath, '.xcodeproj');
2000
+ const schemeResult = injectOrCreateScheme(xcodeprojPath, {
2001
+ appName: plan.target.name,
2002
+ targetUuid: plan.target.uuid,
2003
+ projName,
2004
+ // The scheme pre-action is SYNC-ONLY (no flavor swap). A pre-action swap
2005
+ // could win its race and mask a mismatch from the in-target detector.
2006
+ syncScript: buildSchemePreActionScript(reactNativePath),
2007
+ });
2008
+ log(`Scheme sync pre-action: ${schemeResult.status}`);
2009
+
2010
+ // The RN version this app's xcframework artifact-cache slot should be
2011
+ // pinned to, when `add`/`update` was given an EXPLICIT `--version` — SETS
2012
+ // the pin. Omitting `--version` (opts.artifactsVersionOverride is null)
2013
+ // PRESERVES whatever was recorded on a prior run, since it's an
2014
+ // intentional pin, not something to silently re-derive from
2015
+ // node_modules/react-native/package.json. There is no "clear" verb yet;
2016
+ // `deinit` (removeSpmInjection) drops the whole marker, including this
2017
+ // field. Read back by readArtifactsVersionOverride (above) so the
2018
+ // build-time sync (sync-spm-autolinking.js) heals against the SAME slot
2019
+ // `add`/`update` selected, even on a version-mismatched setup.
2020
+ const artifactsVersionOverride =
2021
+ opts.artifactsVersionOverride ??
2022
+ prevMarker?.artifactsVersionOverride ??
2023
+ null;
2024
+
2025
+ // Marker: idempotency signal + the exact, reversible record of every edit so
2026
+ // `deinit` (removeSpmInjection) can undo precisely what was added.
2027
+ writeIfChanged(
2028
+ path.join(xcodeprojPath, SPM_INJECTED_MARKER),
2029
+ JSON.stringify(
2030
+ {
2031
+ rootUuid: plan.rootUuid,
2032
+ target: plan.target.name,
2033
+ targetUuid: plan.target.uuid,
2034
+ injectedUuids: Array.from(new Set(injectedUuids)).sort(),
2035
+ createdArrayFields,
2036
+ buildSettingChanges,
2037
+ // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next
2038
+ // `update` to reconcile away entries that left the manifest.
2039
+ generatedSources: generatedSourceUuids,
2040
+ artifactsVersionOverride,
2041
+ scheme: {
2042
+ file: schemeResult.file,
2043
+ created: schemeResult.status === 'created',
2044
+ },
2045
+ },
2046
+ null,
2047
+ 2,
2048
+ ) + '\n',
2049
+ );
2050
+
2051
+ ensureStubPackages(appRoot);
2052
+ return {status: 'injected', target: plan.target.name};
2053
+ }
2054
+
2055
+ /**
2056
+ * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added
2057
+ * to a scheme, and drop the `<PreActions>` wrapper if it is left empty (the
2058
+ * byte-identical inverse for the common case where injection created it).
2059
+ */
2060
+ function removePreActionFromScheme(xml /*: string */) /*: string */ {
2061
+ const withoutAction = xml.replace(
2062
+ /[ \t]*<ExecutionAction\b(?:(?!<\/ExecutionAction>)[\s\S])*?title = "Sync SPM Autolinking"(?:(?!<\/ExecutionAction>)[\s\S])*?<\/ExecutionAction>\n?/,
2063
+ '',
2064
+ );
2065
+ return withoutAction.replace(/\n[ \t]*<PreActions>\s*<\/PreActions>/, '');
2066
+ }
2067
+
2068
+ function removeRecordedBuildSettings(
2069
+ input /*: string */,
2070
+ changes /*: ReadonlyArray<BuildSettingChange> */,
2071
+ ) /*: string */ {
2072
+ let text = input;
2073
+ for (const change of changes) {
2074
+ const dict = () => {
2075
+ const config = findObjectByUuid(text, change.configUuid);
2076
+ if (config == null) {
2077
+ return null;
2078
+ }
2079
+ const buildSettings = findField(text, config, 'buildSettings');
2080
+ if (buildSettings == null) {
2081
+ return null;
2082
+ }
2083
+ return {
2084
+ uuid: change.configUuid,
2085
+ bodyOpen: buildSettings.valueStart,
2086
+ bodyClose: buildSettings.tokenEnd - 1,
2087
+ };
2088
+ };
2089
+ for (const key of Object.keys(change.appendedArrayValues ?? {})) {
2090
+ const current = dict();
2091
+ if (current != null) {
2092
+ text = removeArrayStringValues(
2093
+ text,
2094
+ current,
2095
+ key,
2096
+ change.appendedArrayValues[key],
2097
+ );
2098
+ }
2099
+ }
2100
+ for (const key of change.createdArrayKeys ?? []) {
2101
+ const current = dict();
2102
+ if (current != null) {
2103
+ text = removeField(text, current, key);
2104
+ }
2105
+ }
2106
+ for (const key of change.createdScalars ?? []) {
2107
+ const current = dict();
2108
+ if (current != null) {
2109
+ text = removeField(text, current, key);
2110
+ }
2111
+ }
2112
+ const replacedScalars /*: {[string]: string} */ =
2113
+ change.replacedScalars ?? {};
2114
+ for (const key of Object.keys(replacedScalars)) {
2115
+ const current = dict();
2116
+ if (current != null) {
2117
+ text = removeField(text, current, key);
2118
+ const replacement = dict();
2119
+ if (replacement != null) {
2120
+ const originalValue = replacedScalars[key];
2121
+ if (typeof originalValue === 'string') {
2122
+ text = ensureScalarField(text, replacement, key, originalValue);
2123
+ }
2124
+ }
2125
+ }
2126
+ }
2127
+ }
2128
+ return text;
2129
+ }
2130
+
2131
+ /**
2132
+ * The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the
2133
+ * `.spm-injected.json` marker's precise record of every edit, remove only what
2134
+ * injection added — leaving any other (user) edits made afterwards intact. No
2135
+ * `git checkout`, no prompt. Returns {status:'absent'} when the project was
2136
+ * never injected.
2137
+ */
2138
+ function removeSpmInjection(
2139
+ opts /*: {appRoot: string, xcodeprojPath: string} */,
2140
+ ) /*: {status: 'removed', target: string} | {status: 'absent'} */ {
2141
+ const {appRoot, xcodeprojPath} = opts;
2142
+ const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
2143
+ if (!fs.existsSync(markerPath)) {
2144
+ return {status: 'absent'};
2145
+ }
2146
+ // $FlowFixMe[incompatible-type] JSON.parse returns any
2147
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
2148
+ const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
2149
+ let text = fs.readFileSync(pbxprojPath, 'utf8');
2150
+
2151
+ const injectedUuids /*: Array<string> */ = marker.injectedUuids ?? [];
2152
+
2153
+ // 1. Drop our array members, then the array fields we created (now empty),
2154
+ // then the injected object definitions.
2155
+ text = removeArrayMembersByUuid(text, injectedUuids);
2156
+ for (const f of marker.createdArrayFields ?? []) {
2157
+ const obj =
2158
+ f.container === 'project'
2159
+ ? findProjectObject(text)
2160
+ : findObjectByUuid(text, marker.targetUuid);
2161
+ if (obj != null) {
2162
+ text = removeField(text, obj, f.key);
2163
+ }
2164
+ }
2165
+ for (const uuid of injectedUuids) {
2166
+ text = removeObjectByUuid(text, uuid);
2167
+ }
2168
+ // Drop any section that injection created and we just emptied (e.g.
2169
+ // XCLocalSwiftPackageReference) — a well-formed pbxproj never carries an
2170
+ // empty `/* Begin X *​/ /* End X *​/` section, so this lands byte-identical.
2171
+ text = text.replace(
2172
+ /\/\* Begin (\w+) section \*\/\n\/\* End \1 section \*\/\n\n/g,
2173
+ '',
2174
+ );
2175
+
2176
+ // 2. Reverse the per-config build-setting edits (only what we added).
2177
+ text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []);
2178
+ writeIfChanged(pbxprojPath, text);
2179
+ log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`);
2180
+
2181
+ // 3. Scheme: delete it if injection created it, else strip the pre-action.
2182
+ const scheme = marker.scheme;
2183
+ if (scheme != null && scheme.file != null) {
2184
+ const schemePath = path.join(
2185
+ xcodeprojPath,
2186
+ 'xcshareddata',
2187
+ 'xcschemes',
2188
+ scheme.file,
2189
+ );
2190
+ if (scheme.created === true) {
2191
+ fs.rmSync(schemePath, {force: true});
2192
+ } else if (fs.existsSync(schemePath)) {
2193
+ const xml = fs.readFileSync(schemePath, 'utf8');
2194
+ writeIfChanged(schemePath, removePreActionFromScheme(xml));
2195
+ }
2196
+ }
2197
+
2198
+ // 4. Drop the marker — the project is no longer SPM-injected.
2199
+ fs.rmSync(markerPath, {force: true});
2200
+ return {status: 'removed', target: marker.target};
2201
+ }
2202
+
2203
+ module.exports = {
2204
+ generateXcscheme,
2205
+ buildSyncAutolinkingScript,
2206
+ buildSchemePreActionScript,
2207
+ buildEmbedFrameworksScript,
2208
+ flavorForBuildConfiguration,
2209
+ frameworkConditionalSettings,
2210
+ ensureStubPackages,
2211
+ buildSpmDependencyGraph,
2212
+ spmGraphToEntries,
2213
+ planInjection,
2214
+ injectSpmIntoPbxproj,
2215
+ injectSpmIntoExistingXcodeproj,
2216
+ removeSpmInjection,
2217
+ cleanupLeftoverPodsGroup,
2218
+ cleanupDanglingJavaScriptCoreRef,
2219
+ addPreActionToScheme,
2220
+ removePreActionFromScheme,
2221
+ findInjectedXcodeproj,
2222
+ readArtifactsVersionOverride,
2223
+ SPM_INJECTED_MARKER,
2224
+ };