diff --git a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md index c8ac204ca06d..4280c6258d4e 100644 --- a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md @@ -97,6 +97,19 @@ module.exports = function plugin(context) { '/…/node_modules/expo/Package.swift', '/…/node_modules/expo/expo-module.config.json', ], + scriptPhases: [ + // Build-time shell phases on the app target — SwiftPM's missing + // `script_phase`: + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: '"$NODE_BINARY" .../createExpoConfig.js', + position: 'beforeCompile', // default: 'end' + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/EXConstants.bundle/app.config'], + alwaysOutOfDate: true, + }, + ], }; }; ``` @@ -134,6 +147,89 @@ warning. Absolute-only, because the generated phase tests these paths with no cw context. The kept paths are folded into `/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted. +#### `scriptPhases` — build-time shell phases on the app target + +SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must +run a script during the app's build — `expo-constants` writing +`EXConstants.bundle/app.config` is the first consumer — declares it here. Each +entry is recorded to `/.spm-plugin-script-phases.json`, which +`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry +on the injected app target: + +| Key | Meaning | +|---|---| +| `id` | **Stable key** — the ledger entry and the deterministic UUID seed. Charset `/^[@A-Za-z0-9_./-]+$/`, so a scoped npm name like `@expo/log-box` is a valid id; a `:` is not, because the id is hashed into the UUID seed as `plugin:` and the separator must stay unambiguous. Renaming it is a *remove + add*, not a rename. | +| `name` | The phase's display name in Xcode. Any non-empty single-line string — see **Hostile names** below. | +| `script` | The shell body. | +| `position` | `'beforeCompile'` or `'end'`. Optional, default `'end'` — see **Placement** below. | +| `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. | +| `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. | + +**Placement.** `'end'` appends at the true end of the target's `buildPhases` — +after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the +"Sync SPM Autolinking" phase, which stays first because it regenerates the +content everything else reads, and always **before Sources**: React Native never +re-seats its own sync phase, so if you have dragged that below Sources your +`beforeCompile` phases are seated ahead of Sources instead of following it. +Phases sharing a position keep their declared order. + +**Position is enforced on every sync.** `add`/`update` compares where the plugin +phases actually sit in `buildPhases` against the declared placement and, **only +when the two differ**, lifts their membership lines and re-seats them in +declared order. So changing `position` — or swapping two phases that share one — +takes effect on the next `spm add`/`update`, with no remove + re-add. When they +agree nothing is rewritten, which is what keeps an unchanged declaration +re-syncing to a byte-identical project. The consequence worth knowing: a phase +you **drag somewhere else in Xcode is moved back** to its declared position on +the next sync, because the plugin's declaration is the source of truth. Only the +`id` behaves differently — it is a key, not a label, so renaming it is a remove ++ add. + +Phases are injected by `spm add` / `spm update` **only**. The build-time `sync` +rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared +phase appears on the next `add`/`update`, not on the next build. Each phase's +UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's +`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path +lists, `alwaysOutOfDate` and placement in place, `update` removes phases that +left the sidecar, and `deinit` reverts all of them. + +Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a +non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one +plugin or across plugins) aborts the run. A silently dropped phase would produce +a green build whose generated content was never written — a runtime failure with +no build-time signal — and two phases sharing an `id` would collapse onto one +ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even +though the charset admits them: as keys of that ledger they never become own +properties, so the phase would look recorded, disappear when the marker is +serialized, and be unremovable by `deinit`. + +**Hostile names.** A `name` reaches the project file twice. In the `name` field — +what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any +single-line string is expressible. Beside the phase's UUID, on the object's +definition line and on its `buildPhases` member line, it also becomes a +`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the +`name` field) but the text around them is scanned by delimiter, so the name is +**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single +spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id` — +normalized the same way — and then to no comment at all if nothing survives +either. Without that, a `{` in a comment would make the injector read +the next object's body as this one's, and a `,` would make `deinit` delete the +wrong line — corruption with no error. Only a line break is therefore rejected +outright; a name is a display name, and no Xcode phase name spans lines. + +The injector's read of the sidecar is deliberately lenient — the file does not +exist yet on a first `spm add`, and a stale or hand-edited copy must not break +injection. An absent file yields no phases silently, an unparseable one warns, +and a single entry failing the same checks (bad or reserved `id`, empty or +multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is +**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it +enforces exactly the rules the plugin contract does. + +**Gating is the script's job.** The phase runs for every configuration and +platform the target builds; if it should be a no-op for some of them (Release +only, simulator only, …), the script must check `$CONFIGURATION` / `$PLATFORM_NAME` +and exit early. + ### Context (input) | Field | Meaning | @@ -188,6 +284,7 @@ wiring, it stays correct across repackaging. | `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). | | `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | | `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. | +| `scriptPhases` | Recorded for `spm add` / `update` to emit one `PBXShellScriptBuildPhase` per entry on the app target. Malformed entries and duplicate `id`s are fatal. | The plugin returns **data** — it never writes into React Native's generated tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` @@ -237,6 +334,20 @@ message identifying the framework. A framework silently dropping its modules A target without a Sources phase logs loudly and skips the wiring (injection otherwise succeeds). v1 targets only the injected app target and assumes `.swift` in practice (`.m`/`.mm` are mapped as future-proofing). +- **Implemented & tested:** `scriptPhases`, contract through injection. + `invokePlugins` validates every entry fatally (`id` charset plus the reserved + `__proto__`/`constructor`/`prototype` names, a single-line `name`, a required + `script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus + duplicate `id`s), and the merge always + rewrites `.spm-plugin-script-phases.json` — `[]` when no plugin declares any, + so removing a plugin clears stale entries. The `spm add`/`update` xcodeproj + injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry + on the app target at the requested position, recording the id→UUID map in the + `.spm-injected.json` marker so a re-run refreshes each phase's content in + place, re-seats it when its declared position or order changed, `update` + removes phases that left the sidecar, and `deinit` reverts + them. Like `flavoredFrameworks`, the + build-time `sync` only rewrites the sidecar; it never mutates the project. - **Co-design with Expo (not final):** codegen **provider ordering** — codegen must consume the same discovered module set the plugin contributes — is intentionally left for the first real plugin to drive to a stable shape. diff --git a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js index 3128edf75dc8..a219daa3e868 100644 --- a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js +++ b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js @@ -340,4 +340,204 @@ describe('invokePlugins', () => { expect(res.watchPaths).toEqual([]); expect(warnings.some(w => /non-array watchPaths/.test(w))).toBe(true); }); + + it('merges scriptPhases across plugins, preserving optional fields', () => { + const res = invokePlugins( + [ + mk('expo-constants', () => ({ + scriptPhases: [ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + ], + })), + mk('b', () => ({ + scriptPhases: [{id: 'b.stamp', name: 'Stamp', script: 'echo hi'}], + })), + ], + ctx, + ); + expect(res.scriptPhases).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'}, + ]); + }); + + // A package's own npm name is the obvious stable id, and the scoped form is + // the common one (`@expo/log-box` is a named consumer). + it.each([['@expo/log-box'], ['@expo/ui']])( + 'accepts the scoped npm name %s as a scriptPhase id', + id => { + const res = invokePlugins( + [mk('expo', () => ({scriptPhases: [{id, name: 'X', script: 'echo'}]}))], + ctx, + ); + expect(res.scriptPhases).toEqual([ + {id, name: 'X', script: 'echo', position: 'end'}, + ]); + }, + ); + + it('defaults scriptPhases to [] when no plugin declares any', () => { + const res = invokePlugins([mk('a', () => ({}))], ctx); + expect(res.scriptPhases).toEqual([]); + }); + + it('rejects a non-array scriptPhases declaration', () => { + expect(() => + invokePlugins( + [mk('expo', () => ({scriptPhases: {id: 'x', name: 'X', script: 'y'}}))], + ctx, + ), + ).toThrow(/non-array scriptPhases/); + }); + + it.each([ + ['missing id', {name: 'X', script: 'echo'}], + ['empty id', {id: '', name: 'X', script: 'echo'}], + ['non-string id', {id: 7, name: 'X', script: 'echo'}], + ['an id containing a space', {id: 'a b', name: 'X', script: 'echo'}], + // `:` is excluded so the `plugin:` UUID seed stays unambiguous. + ['an id containing a colon', {id: 'expo:phase', name: 'X', script: 'echo'}], + ['missing name', {id: 'a', script: 'echo'}], + ['empty name', {id: 'a', name: '', script: 'echo'}], + ['missing script', {id: 'a', name: 'X'}], + ['empty script', {id: 'a', name: 'X', script: ''}], + [ + 'unknown position', + {id: 'a', name: 'X', script: 'echo', position: 'afterLink'}, + ], + [ + 'non-array inputPaths', + {id: 'a', name: 'X', script: 'echo', inputPaths: '/in'}, + ], + [ + 'non-string inputPaths entry', + {id: 'a', name: 'X', script: 'echo', inputPaths: [7]}, + ], + [ + 'empty inputPaths entry', + {id: 'a', name: 'X', script: 'echo', inputPaths: ['']}, + ], + [ + 'non-array outputPaths', + {id: 'a', name: 'X', script: 'echo', outputPaths: '/out'}, + ], + [ + 'non-string outputPaths entry', + {id: 'a', name: 'X', script: 'echo', outputPaths: [null]}, + ], + [ + 'non-boolean alwaysOutOfDate', + {id: 'a', name: 'X', script: 'echo', alwaysOutOfDate: 'yes'}, + ], + ['null entry', null], + ['a number instead of an entry', 42], + ['a string instead of an entry', 'echo'], + ['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}], + [ + 'the reserved id constructor', + {id: 'constructor', name: 'X', script: 'echo'}, + ], + ['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}], + ])('rejects a scriptPhase with %s', (_label, entry) => { + expect(() => + invokePlugins([mk('expo', () => ({scriptPhases: [entry]}))], ctx), + ).toThrow(/invalid scriptPhase/); + }); + + it('names the offending id in the invalid-scriptPhase error', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [ + {id: 'ok.phase', name: 'Fine', script: 'echo'}, + {id: 'bad:phase', name: 'Bad', script: 'echo'}, + ], + })), + ], + ctx, + ), + ).toThrow(/invalid scriptPhase 'bad:phase'/); + }); + + it('rejects a duplicate scriptPhase id within a single plugin', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [ + {id: 'dup.phase', name: 'One', script: 'echo one'}, + {id: 'dup.phase', name: 'Two', script: 'echo two'}, + ], + })), + ], + ctx, + ), + ).toThrow(/duplicate script phase id 'dup\.phase'/); + }); + + it('rejects a duplicate scriptPhase id across plugins, naming it', () => { + const phase = () => ({id: 'dup.phase', name: 'Dup', script: 'echo'}); + expect(() => + invokePlugins( + [ + mk('a', () => ({scriptPhases: [phase()]})), + mk('b', () => ({scriptPhases: [phase()]})), + ], + ctx, + ), + ).toThrow(/duplicate script phase id 'dup\.phase'/); + }); + + // A line break is the one thing an Xcode phase display name can never carry. + // Every other hostile character is safe by construction: the injector + // normalizes the name for the `/* … */` comments and escapes it in the `name` + // field, so nothing structural reaches the project text. + it.each([ + ['a newline', 'Line one\nLine two'], + ['a carriage return', 'Line one\rLine two'], + ])('rejects a scriptPhase name containing %s', (_label, name) => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [{id: 'expo.phase', name, script: 'echo'}], + })), + ], + ctx, + ), + ).toThrow(/invalid scriptPhase name for 'expo\.phase'/); + }); + + it.each([ + ['pbxproj quoting', 'Bundle "app.config"'], + ['a comment terminator', 'Bad */ = { x'], + ['a comment opener', 'Bad /* opener'], + ])('keeps a scriptPhase name needing %s verbatim', (_label, name) => { + const res = invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [{id: 'expo.phase', name, script: 'echo'}], + })), + ], + ctx, + ); + expect(res.scriptPhases[0].name).toBe(name); + }); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index 16acfb23feda..39939562e151 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -1291,6 +1291,125 @@ describe('main() — flavoredFrameworks sidecar', () => { }); }); +// --------------------------------------------------------------------------- +// main() — plugin scriptPhases sidecar +// +// `.spm-plugin-script-phases.json` records the phases a plugin wants injected +// into the app target (SwiftPM has no `script_phase`). Like the other sidecars +// it is ALWAYS rewritten — `[]` when no plugin declares any — so removing a +// plugin clears stale entries. +// --------------------------------------------------------------------------- + +describe('main() — scriptPhases sidecar', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + const sidecarPath = appRoot => + path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', + ); + + // An app whose only autolinked dep is `expo`; when `pluginReturn` is given, + // expo declares a plugin returning that literal. + function scaffold(pluginReturn) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scriptphase-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + if (pluginReturn != null) { + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); + fs.writeFileSync( + path.join(expoDir, 'react-native.config.js'), + "module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n", + ); + fs.writeFileSync( + path.join(expoDir, 'spm-plugin.js'), + `module.exports = function () { return ${pluginReturn}; };\n`, + ); + } + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: + pluginReturn != null + ? {expo: {root: expoDir, platforms: {ios: {}}}} + : {}, + }), + ); + return {appRoot, rnRoot}; + } + + it('records plugin-declared script phases, normalized', () => { + const {appRoot, rnRoot} = scaffold(`{ + scriptPhases: [{ + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + }, { + id: 'expo-constants.stamp', + name: 'Stamp', + script: 'echo stamped', + }], + }`); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + }, + { + id: 'expo-constants.stamp', + name: 'Stamp', + script: 'echo stamped', + position: 'end', + }, + ]); + }); + + it('writes [] when no plugin declares any (clears stale entries)', () => { + const {appRoot, rnRoot} = scaffold(null); + fs.writeFileSync( + sidecarPath(appRoot), + JSON.stringify([{id: 'stale', name: 'Stale', script: 'echo'}]), + ); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual( + [], + ); + }); +}); + // --------------------------------------------------------------------------- // main() — .spm-sync-watch-paths emission (mixed dirs + files) // diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index d9244637b46f..34af3d715487 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -18,6 +18,7 @@ const { flavorForBuildConfiguration, frameworkConditionalSettings, generateXcscheme, + readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); const {execFileSync} = require('node:child_process'); const fs = require('node:fs'); @@ -226,3 +227,168 @@ describe('embed framework phase script', () => { expect(script).not.toContain('SourcePackages'); }); }); + +describe('readScriptPhasesManifest', () => { + let appRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-script-phases-')); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + const write = contents => { + const dir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, '.spm-plugin-script-phases.json'), + contents, + 'utf8', + ); + }; + + it('is [] when the manifest does not exist (first `spm add`)', () => { + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + }); + + it('is [] for unparseable JSON, warning about it', () => { + write('{not json'); + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + expect( + logSpy.mock.calls.some(([msg]) => + /could not parse .*\.spm-plugin-script-phases\.json/.test(msg), + ), + ).toBe(true); + }); + + it('is [] for a non-array payload', () => { + write('{"id": "x"}'); + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + }); + + it('parses valid entries and defaults position to end', () => { + write( + JSON.stringify([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi'}, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'}, + ]); + }); + + it('skips malformed entries and duplicate ids, keeping the valid ones', () => { + write( + JSON.stringify([ + {name: 'No id', script: 'echo'}, + {id: 'a', name: 'A', script: 'echo one'}, + {id: 'a', name: 'A again', script: 'echo two'}, + null, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'a', name: 'A', script: 'echo one', position: 'end'}, + ]); + }); + + // The same ids the plugin contract accepts: a scoped npm name is the natural + // stable key for a package-owned phase. + it.each([['@expo/log-box'], ['@expo/ui']])( + 'keeps the scoped npm name %s as an id', + id => { + write(JSON.stringify([{id, name: 'X', script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id, name: 'X', script: 'echo', position: 'end'}, + ]); + }, + ); + + it.each([ + // `:` is excluded so the `plugin:` UUID seed stays unambiguous. + ['an id with a colon', {id: 'a:b', name: 'X', script: 'echo'}], + ['an id with a space', {id: 'a b', name: 'X', script: 'echo'}], + ['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}], + [ + 'the reserved id constructor', + {id: 'constructor', name: 'X', script: 'echo'}, + ], + ['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}], + [ + 'an unknown position', + {id: 'a', name: 'X', script: 'echo', position: 'afterLink'}, + ], + // A line break is the one thing no Xcode phase name can carry. This reader + // is the only gate on a stale or hand-edited sidecar. + ['a name with a newline', {id: 'a', name: 'L1\nL2', script: 'echo'}], + [ + 'a name with a carriage return', + {id: 'a', name: 'L1\rL2', script: 'echo'}, + ], + ])('skips an entry with %s', (_label, entry) => { + write(JSON.stringify([entry, {id: 'keep', name: 'Keep', script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'keep', name: 'Keep', script: 'echo', position: 'end'}, + ]); + }); + + // The injector normalizes the name for the `/* … */` comments and escapes it + // in the `name` field, so a pbxproj-hostile name needs no coercion here. + it.each([ + ['needs pbxproj quoting', 'Bundle "app.config"'], + ['closes a comment', 'Bad */ = { x'], + ['opens a comment', 'Bad /* x'], + ])('keeps a name that %s', (_label, name) => { + write(JSON.stringify([{id: 'a', name, script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'a', name, script: 'echo', position: 'end'}, + ]); + }); + + it('drops non-string and empty input/output path entries', () => { + write( + JSON.stringify([ + { + id: 'a', + name: 'A', + script: 'echo', + inputPaths: ['$(SRCROOT)/in', '', 7, null, '$(SRCROOT)/in2'], + outputPaths: [{}, '$(DERIVED_FILE_DIR)/out'], + }, + {id: 'b', name: 'B', script: 'echo', inputPaths: 'not-an-array'}, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + { + id: 'a', + name: 'A', + script: 'echo', + position: 'end', + inputPaths: ['$(SRCROOT)/in', '$(SRCROOT)/in2'], + outputPaths: ['$(DERIVED_FILE_DIR)/out'], + }, + {id: 'b', name: 'B', script: 'echo', position: 'end'}, + ]); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index 89393a28dc01..bc77f7e1eec9 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -11,9 +11,11 @@ 'use strict'; const { + buildPhaseOrder, injectSpmIntoPbxproj, planInjection, } = require('../generate-spm-xcodeproj'); +const {isBalanced} = require('./pbxproj-oracles'); const fs = require('node:fs'); const path = require('node:path'); @@ -69,6 +71,7 @@ function inject( remote = null, hermesCliPath = TEST_HERMES_CLI_PATH, generatedSources = [], + scriptPhases = [], ) { const plan = planInjection(text, {}); expect(plan.ok).toBe(true); @@ -86,9 +89,28 @@ function inject( hermesCliPath, generatedSources, TEST_FRAMEWORKS, + scriptPhases, ); } +// The app target's buildPhases members, in order, by trailing comment. +function buildPhaseComments(text) { + const bp = text.slice(text.indexOf('buildPhases = (')); + const arr = bp.slice(0, bp.indexOf(');')); + return [...arr.matchAll(/\/\* ([^*]+) \*\//g)].map(m => m[1]); +} + +// Move the "Sync SPM Autolinking" membership line below the Sources one — what a +// user dragging the phase down in Xcode produces. RN never re-seats its own sync +// phase, so the move sticks. +function dragSyncBelowSources(text) { + const memberLine = comment => + new RegExp(`\\n[\\t ]*[0-9A-Fa-f]{24} /\\* ${comment} \\*/,`).exec(text)[0]; + const sync = memberLine('Sync SPM Autolinking'); + const sources = memberLine('Sources'); + return text.replace(sync, '').replace(sources, sources + sync); +} + // A normalized generated source under the app root (the Expo case: // build/generated/autolinking/expo/ExpoModulesProvider.swift). `path` is // SRCROOT-relative, so `sourceTree = SOURCE_ROOT`. @@ -99,26 +121,6 @@ const PROVIDER_SOURCE = { fileType: 'sourcecode.swift', }; -// A simple balanced-delimiter check (the injected file must stay well-formed). -function isBalanced(text) { - let depth = 0; - for (let i = 0; i < text.length; i++) { - const c = text[i]; - if (c === '"') { - i++; - while (i < text.length && text[i] !== '"') { - if (text[i] === '\\') i++; - i++; - } - } else if (c === '{' || c === '(') { - depth++; - } else if (c === '}' || c === ')') { - depth--; - } - } - return depth === 0; -} - describe('planInjection', () => { it('accepts a plain SPM-only app and resolves its anchors', () => { const plan = planInjection(PLAIN, {}); @@ -359,6 +361,616 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { }); }); +// A generated source's `name` (its basename) is plugin-derived, so it reaches +// the file reference's, the build file's and the Sources-membership comments +// under the same rules as a script-phase name: normalized, never raw. A `{` +// there makes findObjectByUuid read the next object's body as this one's, and a +// `,` makes removeArrayMembersByUuid chew the wrong line — corruption with no +// error. [label, filename, expected comment] +const HOSTILE_SOURCE_NAMES = [ + ['an opening brace', 'Weird{Name}.swift', 'Weird Name .swift'], + ['a comma', 'Weird,Name.swift', 'Weird Name.swift'], +]; + +describe.each(HOSTILE_SOURCE_NAMES)( + 'injectSpmIntoPbxproj — a generated source whose filename contains %s', + (_label, fileName, comment) => { + const src = { + path: `build/generated/autolinking/expo/${fileName}`, + name: fileName, + sourceTree: 'SOURCE_ROOT', + fileType: 'sourcecode.swift', + }; + + it('normalizes all three comments and keeps the project balanced', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [src]); + const [fileRefUuid, buildFileUuid] = generatedSourceUuids[src.path]; + expect(definitionComment(text, fileRefUuid)).toBe(comment); + expect(definitionComment(text, buildFileUuid)).toBe( + `${comment} in Sources`, + ); + expect(text).toContain(`fileRef = ${fileRefUuid} /* ${comment} */;`); + const sourcesPhase = text.slice( + text.indexOf('/* Begin PBXSourcesBuildPhase section */'), + ); + expect(sourcesPhase.slice(0, sourcesPhase.indexOf('/* End'))).toContain( + `${buildFileUuid} /* ${comment} in Sources */,`, + ); + expect(isBalanced(text)).toBe(true); + }); + + it('leaves the path and name VALUES verbatim', () => { + const {text} = inject(PLAIN, null, null, [src]); + expect(text).toContain(`path = "${src.path}";`); + expect(text).toContain(`name = "${fileName}";`); + }); + + it('re-injects byte-identically', () => { + const first = inject(PLAIN, null, null, [src]).text; + expect(inject(first, null, null, [src]).text).toBe(first); + }); + }, +); + +describe('injectSpmIntoPbxproj — an ordinary generated-source name', () => { + // Normalization must be invisible for every real-world filename, or every + // already-injected project churns on its next sync. + it('reaches all three comments byte-unchanged', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [ + PROVIDER_SOURCE, + ]); + const [fileRefUuid, buildFileUuid] = + generatedSourceUuids[PROVIDER_SOURCE.path]; + expect(definitionComment(text, fileRefUuid)).toBe( + 'ExpoModulesProvider.swift', + ); + expect(definitionComment(text, buildFileUuid)).toBe( + 'ExpoModulesProvider.swift in Sources', + ); + expect(text).toContain( + `fileRef = ${fileRefUuid} /* ExpoModulesProvider.swift */;`, + ); + }); +}); + +// Plugin-declared build phases (the expo-constants case: write app.config into +// the app bundle after the JS bundle phase). +const APP_CONFIG_PHASE = { + id: 'expo-constants.app-config', + name: 'Bundle Expo app.config', + script: 'echo ok > app.config', + position: 'end', + inputPaths: ['$(SRCROOT)/app.json'], + outputPaths: ['$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config'], +}; + +describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { + it('adds one shell script phase carrying the declared name, script and paths', () => { + const {text, scriptPhaseUuids} = inject( + PLAIN, + null, + null, + [], + [APP_CONFIG_PHASE], + ); + // Two RN-owned phases (sync + embed) plus this one. + expect(text.match(/isa = PBXShellScriptBuildPhase;/g)).toHaveLength(3); + + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(uuid).toMatch(/^[0-9A-F]{24}$/); + expect(text).toContain(`${uuid} /* Bundle Expo app.config */ = {`); + expect(text).toContain('name = "Bundle Expo app.config";'); + expect(text).toContain('shellScript = "echo ok > app.config";'); + expect(text).toContain('\t\t\t\t"$(SRCROOT)/app.json",\n'); + expect(text).toContain( + '\t\t\t\t"$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config",\n', + ); + // Recorded so `deinit` reverses it and `update` reconciles it. + const {injectedUuids} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + expect(injectedUuids).toEqual(expect.arrayContaining([uuid])); + expect(isBalanced(text)).toBe(true); + }); + + it('emits an unquoted alwaysOutOfDate = 1 only when the phase asks for it', () => { + const withFlag = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}], + ).text; + expect(withFlag).toContain('alwaysOutOfDate = 1;'); + // Xcode writes it immediately after `isa` — match that to avoid churn. + expect(withFlag).toContain( + 'isa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;', + ); + + for (const phase of [ + APP_CONFIG_PHASE, + {...APP_CONFIG_PHASE, alwaysOutOfDate: false}, + ]) { + expect(inject(PLAIN, null, null, [], [phase]).text).not.toContain( + 'alwaysOutOfDate', + ); + } + }); + + it("places an 'end' phase last in buildPhases", () => { + const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + const comments = buildPhaseComments(text); + expect(comments[comments.length - 1]).toBe('Bundle Expo app.config'); + // NOTE: the fixture target has only Sources/Frameworks/Resources — it has + // no "Bundle React Native code and images" phase, so the real requirement + // (an 'end' phase runs AFTER the JS bundle phase) is not asserted here. + // End-of-array position is what delivers it on a real app target. + }); + + it("places a 'beforeCompile' phase after the sync phase and before Sources", () => { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, position: 'beforeCompile'}], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + 'Bundle Expo app.config', + 'Sources', + ]); + }); + + it('preserves declared order within each position', () => { + const phase = (id, position) => ({ + ...APP_CONFIG_PHASE, + id, + name: id, + position, + }); + const {text} = inject( + PLAIN, + null, + null, + [], + [ + phase('pre-a', 'beforeCompile'), + phase('post-a', 'end'), + phase('pre-b', 'beforeCompile'), + phase('post-b', 'end'), + ], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'pre-a', + 'pre-b', + 'Sources', + ]); + expect(comments.slice(-2)).toEqual(['post-a', 'post-b']); + }); + + it('is idempotent with script phases — a second run is byte-for-byte identical', () => { + const phases = [ + {...APP_CONFIG_PHASE, alwaysOutOfDate: true}, + {...APP_CONFIG_PHASE, id: 'other', name: 'Other', position: 'end'}, + ]; + const first = inject(PLAIN, null, null, [], phases).text; + const second = inject(first, null, null, [], phases).text; + expect(second).toBe(first); + }); + + it('escapes a script carrying quotes, a backslash, a newline and a $(VAR)', () => { + const script = + 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; + const phases = [{...APP_CONFIG_PHASE, script}]; + const {text} = inject(PLAIN, null, null, [], phases); + expect(text).toContain( + 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";', + ); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('quotes a name containing a double quote in the field, dropping it from the comments', () => { + const phases = [{...APP_CONFIG_PHASE, name: 'Bundle "app.config"'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(text).toContain('name = "Bundle \\"app.config\\"";'); + expect(text).toContain(`${uuid} /* Bundle app.config */ = {`); + expect(buildPhaseComments(text)).toContain('Bundle app.config'); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('a rename refreshes the name field AND both /* … */ comments', () => { + const first = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: 'Write App Config'}], + ).text; + const renamed = inject( + first, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: 'Write Expo Config'}], + ).text; + // Xcode normalizes comments on its next write, so a stale one is a spurious + // diff in the user's repo: nothing but the name may differ. + expect(renamed).not.toContain('Write App Config'); + expect(renamed).toBe( + first.split('Write App Config').join('Write Expo Config'), + ); + }); +}); + +// A declared `position` — and the declared order of two phases sharing one — is +// enforced on every sync, not only at first injection. The membership lines are +// rewritten ONLY when the actual order differs, which is what keeps an unchanged +// sync byte-identical. +describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { + const phase = (id, position) => ({ + ...APP_CONFIG_PHASE, + id, + name: id, + position, + }); + + it('moves a phase from end to beforeCompile and back again', () => { + const atEnd = inject(PLAIN, null, null, [], [phase('a', 'end')]).text; + expect(buildPhaseComments(atEnd).slice(-1)).toEqual(['a']); + + const beforeCompile = [phase('a', 'beforeCompile')]; + const moved = inject(atEnd, null, null, [], beforeCompile).text; + expect(buildPhaseComments(moved).slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'Sources', + ]); + expect(isBalanced(moved)).toBe(true); + // Re-syncing the now-matching declaration changes nothing. + expect(inject(moved, null, null, [], beforeCompile).text).toBe(moved); + + // And back: a move is a pure reordering of the membership lines. + expect(inject(moved, null, null, [], [phase('a', 'end')]).text).toBe(atEnd); + }); + + it('reorders two phases sharing a position when their declared order swaps', () => { + const inOrder = inject( + PLAIN, + null, + null, + [], + [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], + ).text; + expect(buildPhaseComments(inOrder).slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'b1', + 'b2', + 'Sources', + ]); + + const swapped = inject( + inOrder, + null, + null, + [], + [phase('b2', 'beforeCompile'), phase('b1', 'beforeCompile')], + ).text; + expect(buildPhaseComments(swapped).slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'b2', + 'b1', + 'Sources', + ]); + }); + + it('reseats only the phase whose position changed', () => { + const declared = [ + phase('a', 'beforeCompile'), + phase('b', 'beforeCompile'), + phase('c', 'beforeCompile'), + ]; + const first = inject(PLAIN, null, null, [], declared).text; + expect(buildPhaseComments(first).slice(0, 5)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'b', + 'c', + 'Sources', + ]); + + const {text} = inject( + first, + null, + null, + [], + [declared[0], {...declared[1], position: 'end'}, declared[2]], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'c', + 'Sources', + ]); + expect(comments[comments.length - 1]).toBe('b'); + }); + + it('moves a phase the user dragged in Xcode back to its declared position', () => { + const phases = [phase('a', 'end')]; + const {text: first, scriptPhaseUuids} = inject( + PLAIN, + null, + null, + [], + phases, + ); + const memberLine = `\n\t\t\t\t${scriptPhaseUuids.a} /* a */,`; + expect(first).toContain(memberLine); + const dragged = first + .replace(memberLine, '') + .replace('buildPhases = (\n', `buildPhases = (${memberLine}\n`); + expect(buildPhaseComments(dragged)[0]).toBe('a'); + + expect(inject(dragged, null, null, [], phases).text).toBe(first); + }); + + // RN never re-seats its own sync phase, so a user who drags it below Sources + // keeps it there — but `beforeCompile` means before Sources, which is the + // guarantee the plugin contract makes. + it('seats a beforeCompile phase before Sources even when the sync phase sits below it', () => { + const dragged = dragSyncBelowSources(inject(PLAIN).text); + expect(buildPhaseComments(dragged).slice(0, 2)).toEqual([ + 'Sources', + 'Sync SPM Autolinking', + ]); + + const declared = [phase('a', 'beforeCompile')]; + const {text} = inject(dragged, null, null, [], declared); + expect(buildPhaseComments(text)).toEqual([ + 'a', + 'Sources', + 'Sync SPM Autolinking', + 'Frameworks', + 'Embed React Native Flavored Frameworks', + 'Resources', + ]); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], declared).text).toBe(text); + }); + + it('falls back to the sync phase as the anchor when the target has no Sources phase', () => { + const noSources = PLAIN.replace( + /\/\* Begin PBXSourcesBuildPhase section \*\/[\s\S]*?\/\* End PBXSourcesBuildPhase section \*\/\n\n/, + '', + ); + const {text} = inject( + noSources, + null, + null, + [], + [phase('a', 'beforeCompile')], + ); + expect(buildPhaseComments(text).slice(0, 2)).toEqual([ + 'Sync SPM Autolinking', + 'a', + ]); + }); + + it.each([ + ['one end phase', [phase('a', 'end')]], + ['one beforeCompile phase', [phase('a', 'beforeCompile')]], + [ + 'two beforeCompile phases', + [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], + ], + ['two end phases', [phase('e1', 'end'), phase('e2', 'end')]], + [ + 'mixed positions', + [ + phase('b1', 'beforeCompile'), + phase('e1', 'end'), + phase('b2', 'beforeCompile'), + phase('e2', 'end'), + ], + ], + ])('re-syncs %s byte-identically', (_label, phases) => { + const first = inject(PLAIN, null, null, [], phases).text; + expect(inject(first, null, null, [], phases).text).toBe(first); + }); +}); + +// The membership order drives every re-seating decision, so it must list the +// members and nothing else: a `/* … */` comment is arbitrary plugin-supplied +// text, and a phase NAMED like a UUID would otherwise read as an extra member — +// making the actual order permanently disagree with the declared one. +describe('buildPhaseOrder', () => { + const PHANTOM = 'ABCDEF012345678901234567'; + + it('lists only line-leading UUIDs, never one inside a comment', () => { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: PHANTOM}], + ); + const plan = planInjection(text, {}); + const order = buildPhaseOrder(text, plan.target); + + expect(text).toContain(`/* ${PHANTOM} */,`); + expect(order).not.toContain(PHANTOM); + expect(order).toHaveLength(buildPhaseComments(text).length); + }); + + it('seats a phase named like a UUID normally, and re-syncs byte-identically', () => { + const phases = [ + {...APP_CONFIG_PHASE, name: PHANTOM, position: 'beforeCompile'}, + ]; + const first = inject(PLAIN, null, null, [], phases).text; + expect(buildPhaseComments(first).slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + PHANTOM, + 'Sources', + ]); + expect(inject(first, null, null, [], phases).text).toBe(first); + }); +}); + +// The trailing comment beside a UUID on its object-definition line. +function definitionComment(text, uuid) { + const m = new RegExp(`\\n\\t*${uuid}(?: /\\* (.*?) \\*/)? = \\{`).exec(text); + return m == null ? null : (m[1] ?? null); +} + +// A pbxproj `/* … */` comment is cosmetic — Xcode regenerates it from the +// object's own `name` field — so a plugin-supplied name is NORMALIZED for the +// comment and kept verbatim (quoted) in the field. Nothing a scanner could read +// as structure may survive into a comment: findObjectByUuid takes the first `{` +// after the UUID as the object's body, and removeArrayMembersByUuid identifies a +// member line by its trailing comma — so a `{` or a `,` in a comment splices +// fields into the wrong object, or makes `deinit` chew the section header. +// [label, name, expected comment] +const HOSTILE_NAMES = [ + ['an opening brace', 'Bundle { app', 'Bundle app'], + ['a closing brace', 'Bundle } app', 'Bundle app'], + ['an opening paren', 'Bundle (app', 'Bundle app'], + ['a closing paren', 'Bundle app)', 'Bundle app'], + ['a comma', 'A , B', 'A B'], + ['a semicolon', 'A; B', 'A B'], + ['an equals sign', 'name = {', 'name'], + ['a comment terminator', 'Bad */ = { x', 'Bad x'], + ['a comment opener', 'Bad /* x', 'Bad x'], + ['a bare asterisk', 'A * B', 'A B'], + ['a bare slash', 'Copy A/B', 'Copy A B'], + ['an unbalanced double quote', 'He said "hi', 'He said hi'], + ['a balanced double-quote pair', 'Bundle "app.config"', 'Bundle app.config'], + ['a tab', 'A\tB', 'A B'], + ['non-ASCII characters', 'Générer la config 📦', 'Générer la config 📦'], + ['300 characters', `Bundle ${'x'.repeat(300)}`, `Bundle ${'x'.repeat(300)}`], + // Nothing printable survives normalization — fall back to the phase id, + // itself normalized (see the scoped-id describe below). + ['only structural characters', '*/*', APP_CONFIG_PHASE.id], +]; + +describe.each(HOSTILE_NAMES)( + 'injectSpmIntoPbxproj — a plugin phase name containing %s', + (_label, name, comment) => { + const phases = [{...APP_CONFIG_PHASE, name}]; + + it('normalizes it in both comments and keeps the project balanced', () => { + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(definitionComment(text, uuid)).toBe(comment); + expect(buildPhaseComments(text)).toContain(comment); + expect(isBalanced(text)).toBe(true); + // The phase object is intact — the sanity check that nothing was spliced + // into a neighbouring object through a comment-borne `{`. + expect(text).toContain(`${uuid} /* ${comment} */ = {`); + expect(text.match(/isa = PBXShellScriptBuildPhase;/g)).toHaveLength(3); + }); + + it('re-injects byte-identically', () => { + const first = inject(PLAIN, null, null, [], phases).text; + expect(inject(first, null, null, [], phases).text).toBe(first); + }); + }, +); + +// A scoped npm package name is the natural stable id for a package-owned phase +// (`@expo/log-box` is a named consumer), so `@` and `/` are in the id charset. +// The id is also the comment's fallback, and it is normalized there exactly like +// a name — the comment must not depend on which characters the charset admits. +describe('injectSpmIntoPbxproj — a scoped-npm-name phase id', () => { + const SCOPED = {...APP_CONFIG_PHASE, id: '@expo/log-box'}; + + it('keys the phase UUID on the scoped id verbatim', () => { + const {scriptPhaseUuids} = inject(PLAIN, null, null, [], [SCOPED]); + expect(Object.keys(scriptPhaseUuids)).toEqual(['@expo/log-box']); + expect(scriptPhaseUuids['@expo/log-box']).toMatch(/^[0-9A-F]{24}$/); + }); + + it('normalizes the id when nothing in the name survives', () => { + const phases = [{...SCOPED, name: '*/*'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids['@expo/log-box']; + expect(definitionComment(text, uuid)).toBe('@expo log-box'); + expect(buildPhaseComments(text)).toContain('@expo log-box'); + expect(text).toContain(`${uuid} /* @expo log-box */ = {`); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('writes no comment at all when neither the name nor the id survives', () => { + const phases = [{...APP_CONFIG_PHASE, id: '//', name: '*/*'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids['//']; + expect(definitionComment(text, uuid)).toBe(null); + expect(text).toContain(`${uuid} = {`); + expect(text).toMatch(new RegExp(`\\n\\t+${uuid},`)); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); +}); + +describe('injectSpmIntoPbxproj — phase name field vs. comment', () => { + it('keeps the raw name in the escaped `name` field Xcode displays', () => { + const cases = [ + ['Bundle "app.config"', 'name = "Bundle \\"app.config\\"";'], + ['A\tB', 'name = "A\\tB";'], + ['name = {', 'name = "name = {";'], + ['*/*', 'name = "*/*";'], + ]; + for (const [name, expected] of cases) { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name}], + ); + expect(text).toContain(expected); + } + }); + + it("leaves React Native's own two phase comments byte-identical", () => { + const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + for (const label of [ + 'Sync SPM Autolinking', + 'Embed React Native Flavored Frameworks', + ]) { + expect(text).toMatch( + new RegExp(`\\n\\t\\t[0-9A-F]{24} /\\* ${label} \\*/ = \\{`), + ); + expect(text).toMatch( + new RegExp(`\\n\\t\\t\\t\\t[0-9A-F]{24} /\\* ${label} \\*/,`), + ); + } + }); + + // Xcode's own comment convention for a package reference carries quotes and + // slashes. Normalizing those would rewrite bytes Xcode itself produces, so + // only untrusted labels go through commentSafe. + it("preserves Xcode's comment convention for the package references", () => { + const {text} = inject( + PLAIN, + null, + null, + [PROVIDER_SOURCE], + [APP_CONFIG_PHASE], + ); + expect(text).toContain( + '/* XCLocalSwiftPackageReference "build/generated/autolinking" */', + ); + expect(text).toContain('/* ExpoModulesProvider.swift in Sources */'); + expect(text).toContain('/* ReactHeaders in Frameworks */'); + }); +}); + describe('injectSpmIntoPbxproj — invariants', () => { it('produces a balanced (well-formed) pbxproj', () => { const {text} = inject(PLAIN); diff --git a/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js b/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js new file mode 100644 index 000000000000..c7330315b41a --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js @@ -0,0 +1,33 @@ +/** + * 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. + * + * @format + * @noflow + */ + +'use strict'; + +// A simple balanced-delimiter check (an injected project must stay well-formed). +function isBalanced(text) { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') i++; + i++; + } + } else if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + } + } + return depth === 0; +} + +module.exports = {isBalanced}; diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 4fe3d342fdc7..fbc08fc34bb6 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -16,6 +16,7 @@ const { readArtifactsVersionOverride, removeSpmInjection, } = require('../generate-spm-xcodeproj'); +const {isBalanced} = require('./pbxproj-oracles'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -87,9 +88,41 @@ function writeManifest(appRoot, relPaths) { ); } +const SCRIPT_PHASES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', +); + +function writeScriptPhases(appRoot, phases) { + const manifestPath = path.join(appRoot, SCRIPT_PHASES_MANIFEST); + fs.mkdirSync(path.dirname(manifestPath), {recursive: true}); + fs.writeFileSync(manifestPath, JSON.stringify(phases, null, 2), 'utf8'); +} + +const APP_CONFIG_PHASE = { + // Contract charset (/^[@A-Za-z0-9_./-]+$/) — the reader skips anything else. + id: 'expo-constants.app-config', + name: 'Bundle Expo app.config', + script: 'echo v1', + position: 'end', +}; + +function markerTextOf(xcodeprojPath) { + return fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'); +} + function readMarker(xcodeprojPath) { - return JSON.parse( - fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'), + return JSON.parse(markerTextOf(xcodeprojPath)); +} + +function schemePathOf(xcodeprojPath) { + return path.join( + xcodeprojPath, + 'xcshareddata', + 'xcschemes', + 'MyApp.xcscheme', ); } @@ -120,6 +153,42 @@ describe('removeSpmInjection — the surgical inverse of add', () => { ); }); + // Both records the second run relies on — `createdArrayFields` and + // `scheme.created` — are carried forward for the reason spelled out on + // mergeCreatedArrayFields. Without that, this second marker forgets them and + // `deinit` leaves an empty `packageReferences` / `packageProductDependencies` + // behind and the generated scheme on disk. Zero script phases here: the defect + // is in the injector's reversal record, not in any one feature. + it('round-trips add → update → deinit byte-for-byte, scheme included', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + const marker = markerTextOf(xcodeprojPath); + const schemePath = schemePathOf(xcodeprojPath); + expect(fs.existsSync(schemePath)).toBe(true); + + sync(); + // The re-sync changed neither the project… + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + // …nor the record of what has to be undone. + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toMatch(/packageReferences/); + expect(after).not.toMatch(/packageProductDependencies/); + expect(after).toBe(before); + expect(fs.existsSync(schemePath)).toBe(false); + }); + it('preserves an unrelated edit made to the pbxproj after add', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); @@ -189,6 +258,204 @@ describe('removeSpmInjection — the surgical inverse of add', () => { }); }); +// --------------------------------------------------------------------------- +// Scheme ownership. `scheme.created` records that RN wrote the file, which is +// necessary but not sufficient to delete it on `deinit`: the user may have +// replaced its contents with a scheme of their own (same name, same target). +// Deleting that would destroy their work, so the file is only removed while its +// contents are still the ones RN generates. +// --------------------------------------------------------------------------- +describe('deinit — scheme ownership', () => { + // A scheme the user authored for the same target: same file name, same + // BlueprintIdentifier, no PreActions. + function userAuthoredScheme(targetUuid) { + return ` + + + + + + + + + + + + +`; + } + + function setUp() { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const deinit = () => removeSpmInjection({appRoot, xcodeprojPath}); + const schemePath = schemePathOf(xcodeprojPath); + const readScheme = () => fs.readFileSync(schemePath, 'utf8'); + return {xcodeprojPath, sync, deinit, schemePath, readScheme}; + } + + it('deletes the scheme it created while the file is still its own', () => { + const {sync, deinit, schemePath} = setUp(); + sync(); + sync(); + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); + + it("keeps the user's own scheme when they replaced the generated one", () => { + const {xcodeprojPath, sync, deinit, schemePath, readScheme} = setUp(); + sync(); + const mine = userAuthoredScheme(readMarker(xcodeprojPath).targetUuid); + fs.writeFileSync(schemePath, mine, 'utf8'); + + // The second sync re-adds the pre-action to the file the user now owns, and + // still records that RN originally created it. + sync(); + expect(readScheme()).toContain('Sync SPM Autolinking'); + expect(readMarker(xcodeprojPath).scheme.created).toBe(true); + + expect(deinit().status).toBe('removed'); + // Their file survives, with only RN's pre-action stripped back out. + expect(fs.existsSync(schemePath)).toBe(true); + expect(readScheme()).toBe(mine); + }); + + it('keeps its own scheme once the user has edited it', () => { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace( + 'buildConfiguration = "Release"\n revealArchiveInOrganizer', + 'buildConfiguration = "Debug"\n revealArchiveInOrganizer', + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + // Leaking a scheme beats deleting an edit: the file stays, minus the + // pre-action. + expect(fs.existsSync(schemePath)).toBe(true); + expect(readScheme()).not.toContain('Sync SPM Autolinking'); + expect(readScheme()).toContain( + ' { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace( + 'scriptText = "set -euo pipefail', + 'scriptText = "echo hi', + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// createdArrayFields — "this array field did not exist before RN touched it". +// It licenses removing the field at `deinit`, but only once RN's own members are +// gone AND nothing else is left in it: a package the user added to the same +// field is theirs, and dropping the field would orphan it. +// --------------------------------------------------------------------------- +describe('deinit — array fields RN created', () => { + it('keeps a created field the user added their own package to', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).createdArrayFields).toEqual( + expect.arrayContaining([ + {container: 'project', key: 'packageReferences'}, + ]), + ); + + // The user adds their own remote package in Xcode, after injection. + const userRef = 'DEADBEEF0000000000000001'; + const userMember = `${userRef} /* XCRemoteSwiftPackageReference "swift-log" */,`; + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxprojOf(xcodeprojPath).replace( + 'packageReferences = (\n', + `packageReferences = (\n\t\t\t\t${userMember}\n`, + ), + 'utf8', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + // Their member — and the field holding it — survive… + expect(after).toContain(userMember); + expect(after).toMatch(/packageReferences = \(/); + // …while RN's own members go, as does the field RN created and emptied. + expect(after).not.toMatch(/relativePath = build\/xcframeworks/); + expect(after).not.toMatch(/packageProductDependencies/); + }); + + it('restores a field that pre-existed but was empty byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + // An empty `packageReferences` the user already had (Xcode leaves one behind + // after removing the last package). RN never records it as created, so the + // field itself must outlive `deinit`. + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + PLAIN.replace( + '\t\t\tprojectDirPath = "";', + '\t\t\tpackageReferences = (\n\t\t\t);\n\t\t\tprojectDirPath = "";', + ), + 'utf8', + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).createdArrayFields).not.toEqual( + expect.arrayContaining([ + {container: 'project', key: 'packageReferences'}, + ]), + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); + describe('generated-sources reconciliation on update', () => { it('removes exactly the UUIDs of an entry dropped from the manifest, keeping the rest', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); @@ -269,6 +536,42 @@ describe('generated-sources reconciliation on update', () => { expect(readMarker(xcodeprojPath).generatedSources).toEqual({}); }); + // A plugin-supplied filename reaches three `/* … */` comments. Whatever it + // contains, `deinit` must find the file reference, the build file and their + // memberships again and leave the file as it was — the comment-normalization + // contract these lean on lives in inject-spm-xcodeproj-test.js. + it.each([ + ['an opening brace', 'Weird{Name}.swift'], + ['a comma', 'Weird,Name.swift'], + ])( + 'deinits a generated source whose filename contains %s, leaving no residue', + (_label, fileName) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const rel = `build/generated/autolinking/expo/${fileName}`; + writeManifest(appRoot, [rel]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuids = readMarker(xcodeprojPath).generatedSources[rel]; + expect(uuids).toHaveLength(2); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + const after = pbxprojOf(xcodeprojPath); + for (const uuid of uuids) { + expect(after).not.toContain(uuid); + } + expect(after).not.toContain('SPM Generated Sources'); + expect(after).not.toContain(fileName); + expect(after).toBe(before); + }, + ); + it('injects nothing generated-source-related when no manifest exists', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); injectSpmIntoExistingXcodeproj({ @@ -282,6 +585,311 @@ describe('generated-sources reconciliation on update', () => { }); }); +describe('script-phases reconciliation on update', () => { + it('records an added phase in the marker keyed on its plugin id', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const {scriptPhases} = readMarker(xcodeprojPath); + expect(Object.keys(scriptPhases)).toEqual([APP_CONFIG_PHASE.id]); + const uuid = scriptPhases[APP_CONFIG_PHASE.id]; + expect(uuid).toMatch(/^[0-9A-F]{24}$/); + expect(pbxprojOf(xcodeprojPath)).toContain( + `${uuid} /* Bundle Expo app.config */ = {`, + ); + }); + + it('removes the phase object AND its buildPhases membership when the id leaves the manifest', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + + writeScriptPhases(appRoot, []); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain(uuid); + expect(after).not.toContain('Bundle Expo app.config'); + // The RN-owned phases are untouched. + expect(after).toContain('Sync SPM Autolinking'); + expect(readMarker(xcodeprojPath).scriptPhases).toEqual({}); + }); + + it('refreshes a changed script in place, leaving every other byte alone', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + expect(first).toContain('shellScript = "echo v1";'); + + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: 'echo v2'}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(pbxprojOf(xcodeprojPath)).toBe( + first.replace('"echo v1"', '"echo v2"'), + ); + }); + + // The declared position is enforced on every sync, not only at first + // injection — see inject-spm-xcodeproj-test.js for the ordering matrix. Here: + // it survives the sidecar/marker path, and `deinit` still reverses it. + it('re-seats a phase whose declared position changed, and still deinits cleanly', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + sync(); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + const memberLine = `${uuid} /* Bundle Expo app.config */,`; + const atEnd = pbxprojOf(xcodeprojPath); + const sourcesAt = atEnd.indexOf('Sources */,'); + expect(atEnd.indexOf(memberLine)).toBeGreaterThan(sourcesAt); + + writeScriptPhases(appRoot, [ + {...APP_CONFIG_PHASE, position: 'beforeCompile'}, + ]); + sync(); + const moved = pbxprojOf(xcodeprojPath); + expect(moved.indexOf(memberLine)).toBeLessThan( + moved.indexOf('Sources */,'), + ); + // Moved, not duplicated. + expect(moved.split(memberLine)).toHaveLength(2); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('round-trips add → update → deinit byte-for-byte with unchanged phases', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [ + APP_CONFIG_PHASE, + { + ...APP_CONFIG_PHASE, + id: 'other', + name: 'Other', + position: 'beforeCompile', + alwaysOutOfDate: true, + inputPaths: ['$(SRCROOT)/app.json'], + }, + ]); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain('Bundle Expo app.config'); + const marker = markerTextOf(xcodeprojPath); + + sync(); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(fs.existsSync(schemePathOf(xcodeprojPath))).toBe(false); + }); + + // A scoped npm name is a valid id, and it is also a JSON ledger key in the + // marker — the only handle `deinit` has on the phase it injected. + it('round-trips a scoped npm-name id through the marker and deinits byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, id: '@expo/log-box'}]); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + const marker = markerTextOf(xcodeprojPath); + expect(isBalanced(injected)).toBe(true); + expect(Object.keys(readMarker(xcodeprojPath).scriptPhases)).toEqual([ + '@expo/log-box', + ]); + expect(marker).toContain('"@expo/log-box"'); + const uuid = readMarker(xcodeprojPath).scriptPhases['@expo/log-box']; + expect(injected).toContain(`${uuid} /* Bundle Expo app.config */ = {`); + + sync(); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain(uuid); + expect(after).not.toContain('PBXShellScriptBuildPhase'); + expect(after).toBe(before); + }); + + it('removes alwaysOutOfDate when a plugin flips it back off', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain('alwaysOutOfDate = 1;'); + + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: false}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('alwaysOutOfDate'); + expect(after).toContain('Bundle Expo app.config'); + + // …and back on again, stably (the field is re-added to an existing object, + // so it lands ahead of `isa` rather than after it — order is not semantic + // in a pbxproj, and a further re-sync must be a no-op). + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const back = pbxprojOf(xcodeprojPath); + expect(back).toContain('alwaysOutOfDate = 1;'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(back); + }); + + // A shell body full of pbxproj-hostile characters: double quotes, a + // backslash, a real newline and an Xcode `$(VAR)`. + const AWKWARD_SCRIPT = + 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; + const AWKWARD_ESCAPED = + 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";'; + + it('escapes an awkward script and refreshes it byte-identically on update', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: AWKWARD_SCRIPT}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain(AWKWARD_ESCAPED); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + }); + + it('deinit restores the pbxproj byte-for-byte after an awkward script', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: AWKWARD_SCRIPT}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain(AWKWARD_ESCAPED); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + // The hostile-name matrix, from the other side: whatever a plugin calls its + // phase, `deinit` must find the object and its membership again and leave the + // file as it was. The comment-normalization contract these lean on (and the + // per-name balance/idempotency assertions) lives in + // inject-spm-xcodeproj-test.js. + it.each([ + ['an opening brace', 'Bundle { app'], + ['a closing brace', 'Bundle } app'], + ['an opening paren', 'Bundle (app'], + ['a closing paren', 'Bundle app)'], + ['a comma', 'A , B'], + ['a semicolon', 'A; B'], + ['an equals sign', 'name = {'], + ['a comment terminator', 'Bad */ = { x'], + ['a comment opener', 'Bad /* x'], + ['a bare asterisk', 'A * B'], + ['a bare slash', 'Copy A/B'], + ['an unbalanced double quote', 'He said "hi'], + ['a balanced double-quote pair', 'Bundle "app.config"'], + ['a tab', 'A\tB'], + ['non-ASCII characters', 'Générer la config 📦'], + ['300 characters', `Bundle ${'x'.repeat(300)}`], + ['only structural characters', '*/*'], + ])( + 'deinits a phase whose name contains %s, leaving no residue', + (_label, name) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, name}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + expect(pbxprojOf(xcodeprojPath)).toContain(uuid); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('PBXShellScriptBuildPhase'); + expect(after).not.toContain(uuid); + expect(after).toBe(before); + }, + ); +}); + // --------------------------------------------------------------------------- // artifactsVersionOverride — the marker field persisting an explicit // `spm add/update --version ` pin (see setup-apple-spm.js / diff --git a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js index 2ac7297d4ce7..7a026413b826 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -13,6 +13,7 @@ const { addArrayMembers, addArrayStringValues, + commentSafe, ensureScalarField, findApplicationTargets, findField, @@ -71,11 +72,45 @@ describe('quoteIfNeeded', () => { ['a\\b', '"a\\\\b"'], ['a"b', '"a\\"b"'], ['', '""'], + ['a\nb', '"a\\nb"'], + // A literal CR or tab inside a quoted value is legal, but Xcode rewrites it + // to its escape on the next save — a spurious diff in the user's repo. A + // plugin `script` with CRLF line endings is the way one gets in. + ['a\r\nb', '"a\\r\\nb"'], + ['a\tb', '"a\\tb"'], ])('quoteIfNeeded(%j) => %j', (input, expected) => { expect(quoteIfNeeded(input)).toBe(expected); }); }); +// --------------------------------------------------------------------------- +// commentSafe — a pbxproj `/* … */` comment is cosmetic (Xcode regenerates it +// from the object's fields), so nothing a scanner could read as structure is +// allowed to reach one. +// --------------------------------------------------------------------------- + +describe('commentSafe', () => { + it.each([ + ['Sync SPM Autolinking', 'Sync SPM Autolinking'], + ['Générer la config 📦', 'Générer la config 📦'], + ['Bad */ = { x', 'Bad x'], + ['A , B', 'A B'], + ['He said "hi', 'He said hi'], + ['A\tB', 'A B'], + ['a\r\nb', 'a b'], + ['Copy A/B', 'Copy A B'], + ['*/*', ''], + [' padded ', 'padded'], + ])('commentSafe(%j) => %j', (input, expected) => { + expect(commentSafe(input)).toBe(expected); + }); + + it('is stable under repetition (so a re-sync stays byte-identical)', () => { + const once = commentSafe('Bad */ = { x, y'); + expect(commentSafe(once)).toBe(once); + }); +}); + // --------------------------------------------------------------------------- // Surgical-edit toolkit (in-place injection primitives) // --------------------------------------------------------------------------- @@ -377,6 +412,15 @@ describe('surgical removal (deinit inverse)', () => { expect(removeField(added, target2, 'SPM_TEST_FLAG')).toBe(PLAIN_PBXPROJ); }); + // What makes it safe for the marker to carry a created-field record forward + // across syncs: a field the user has since deleted by hand costs nothing. + it('removeField is a no-op when the field is absent', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + expect(removeField(PLAIN_PBXPROJ, target, 'SPM_TEST_FLAG')).toBe( + PLAIN_PBXPROJ, + ); + }); + it('removeArrayStringValues removes only the named values', () => { const [target] = findApplicationTargets(PLAIN_PBXPROJ); const seeded = addArrayStringValues(PLAIN_PBXPROJ, target, 'SPM_TEST_ARR', [ diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js index c218a3960264..f3777c230e08 100644 --- a/packages/react-native/scripts/spm/autolinking-plugins.js +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -69,6 +69,14 @@ * // dir add/remove there re-triggers the sync. Relative/empty/non-string * // entries are dropped with a warning; a non-array is ignored. * watchPaths: ['/abs/path/Package.swift', '/abs/dir'], + * // Build-time shell phases on the app target — SwiftPM has no + * // `script_phase`. Only id/name/script are required; `id` is the + * // ledger key and UUID seed, `position` defaults to 'end'. Recorded + * // to `.spm-plugin-script-phases.json`, from which the `spm add`/ + * // `update` injector emits a PBXShellScriptBuildPhase per entry. + * // Malformed entries and duplicate ids are fatal. + * scriptPhases: [{id, name, script, position, inputPaths, outputPaths, + * alwaysOutOfDate}], * }; * }; * @@ -76,12 +84,14 @@ * the merge, so regeneration stays deterministic and idempotent. */ +const {isValidScriptPhaseId, isValidScriptPhaseName} = require('./spm-utils'); const path = require('node:path'); /*:: import type { AutolinkedDep, PluginContext, PluginResult, + PluginScriptPhase, DiscoveredPlugin, } from './spm-types'; */ @@ -149,6 +159,12 @@ function discoverPlugins( return found; } +const isNonEmptyString = (value /*: unknown */) /*: boolean */ => + typeof value === 'string' && value.length > 0; + +const isOptionalStringList = (value /*: unknown */) /*: boolean */ => + value == null || (Array.isArray(value) && value.every(isNonEmptyString)); + /** * Invoke discovered plugins and merge their results. Each plugin gets the same * context. Fail-closed: a throwing plugin aborts (named), and a malformed @@ -169,10 +185,12 @@ function invokePlugins( const flavoredFrameworks /*: Array<{id: string, frameworkName: string, linkage: 'dynamic', flavors: {debug: string, release: string}}> */ = []; const watchPaths /*: Array */ = []; + const scriptPhases /*: Array */ = []; const seenPackages /*: Set */ = new Set(); const seenProducts /*: Set */ = new Set(); const seenFrameworkIds /*: Set */ = new Set(); const seenFrameworkNames /*: Set */ = new Set(); + const seenScriptPhaseIds /*: Set */ = new Set(); for (const {depName, pluginPath, plugin} of plugins) { let result /*: unknown */ = null; @@ -203,11 +221,18 @@ function invokePlugins( const rawFrameworks = result.flavoredFrameworks ?? []; // $FlowFixMe[incompatible-use] const rawWatch = result.watchPaths ?? []; + // $FlowFixMe[incompatible-use] + const rawScriptPhases = result.scriptPhases ?? []; if (!Array.isArray(rawFrameworks)) { throw new Error( `react-native spm: '${depName}' returned a non-array flavoredFrameworks.`, ); } + if (!Array.isArray(rawScriptPhases)) { + throw new Error( + `react-native spm: '${depName}' returned a non-array scriptPhases.`, + ); + } // Watch paths are best-effort staleness hints, so // a non-array is ignored (warn, never fatal). if (!Array.isArray(rawWatch)) { @@ -314,6 +339,60 @@ function invokePlugins( } watchPaths.push(w); } + // Fatal like flavoredFrameworks, not dropped like watchPaths: a missing + // phase produces a GREEN build whose generated content was never written. + for (const phase of rawScriptPhases) { + if ( + phase == null || + !isValidScriptPhaseId(phase.id) || + !isNonEmptyString(phase.script) || + (phase.position != null && + phase.position !== 'beforeCompile' && + phase.position !== 'end') || + !isOptionalStringList(phase.inputPaths) || + !isOptionalStringList(phase.outputPaths) || + (phase.alwaysOutOfDate != null && + typeof phase.alwaysOutOfDate !== 'boolean') + ) { + const named = typeof phase?.id === 'string' ? ` '${phase.id}'` : ''; + throw new Error( + `react-native spm: '${depName}' returned an invalid scriptPhase${named} ` + + '(need {id (matching /^[@A-Za-z0-9_./-]+$/, and not __proto__, ' + + 'constructor or prototype), name, script} plus optional ' + + '{position: "beforeCompile" | "end", inputPaths, outputPaths, ' + + 'alwaysOutOfDate}).', + ); + } + if (!isValidScriptPhaseName(phase.name)) { + throw new Error( + `react-native spm: '${depName}' returned an invalid scriptPhase name for ` + + `'${phase.id}' (a name must be a non-empty single-line string — it is ` + + "the phase's display name in Xcode).", + ); + } + if (seenScriptPhaseIds.has(phase.id)) { + throw new Error( + `react-native spm: duplicate script phase id '${phase.id}'.`, + ); + } + seenScriptPhaseIds.add(phase.id); + const normalized /*: PluginScriptPhase */ = { + id: phase.id, + name: phase.name, + script: phase.script, + position: phase.position ?? 'end', + }; + if (phase.inputPaths != null) { + normalized.inputPaths = [...phase.inputPaths]; + } + if (phase.outputPaths != null) { + normalized.outputPaths = [...phase.outputPaths]; + } + if (phase.alwaysOutOfDate != null) { + normalized.alwaysOutOfDate = phase.alwaysOutOfDate; + } + scriptPhases.push(normalized); + } } return { @@ -322,6 +401,7 @@ function invokePlugins( generatedSources, flavoredFrameworks, watchPaths, + scriptPhases, }; } diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index 058d6c3e4da2..5e933af01443 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -19,6 +19,7 @@ PluginFlavoredFramework, PluginPackageDep, PluginProductDep, + PluginScriptPhase, ReactDescriptor, RawAutolinkingJson, SpmModuleConfig, @@ -1686,6 +1687,7 @@ function main(argv /*:: ?: Array */) /*: void */ { let pluginGeneratedSources /*: Array<{path: string}> */ = []; let pluginFlavoredFrameworks /*: Array */ = []; let pluginWatchPaths /*: Array */ = []; + let pluginScriptPhases /*: Array */ = []; if (discoveredPlugins.length > 0) { // React-GeneratedCode is the per-app codegen package (referenced as // `../ios` from outputDir). It may be absent (no codegen this run), so the @@ -1714,15 +1716,17 @@ function main(argv /*:: ?: Array */) /*: void */ { pluginGeneratedSources = result.generatedSources; pluginFlavoredFrameworks = result.flavoredFrameworks; pluginWatchPaths = result.watchPaths; + pluginScriptPhases = result.scriptPhases; log( `SPM plugins contributed ${pluginPackageDeps.length} package(s), ` + `${pluginProductDeps.length} product(s), ` + `${pluginGeneratedSources.length} generated source(s), ` + - `${pluginFlavoredFrameworks.length} flavored framework(s)`, + `${pluginFlavoredFrameworks.length} flavored framework(s), ` + + `${pluginScriptPhases.length} script phase(s)`, ); } - // Plugin sidecars. Both are ALWAYS written — even `[]` — so removing a + // Plugin sidecars. All are ALWAYS written — even `[]` — so removing a // plugin (or dropping its declaration) clears stale entries. Machine-local // absolute paths; gitignored + regenerated every sync. fs.mkdirSync(outputDir, {recursive: true}); @@ -1741,6 +1745,11 @@ function main(argv /*:: ?: Array */) /*: void */ { JSON.stringify(pluginFlavoredFrameworks, null, 2) + '\n', 'utf8', ); + fs.writeFileSync( + path.join(outputDir, '.spm-plugin-script-phases.json'), + JSON.stringify(pluginScriptPhases, null, 2) + '\n', + 'utf8', + ); // Top-level aggregator: references every entry as .package(path:) and // depends on each via .product(...). No more inline targets — every diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index e75af429ce29..0cb84cb93ffb 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -25,6 +25,7 @@ const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); const { addArrayMembers, addArrayStringValues, + commentSafe, ensureScalarField, findApplicationTargets, findField, @@ -41,13 +42,20 @@ const { removeObjectByUuid, serializeEntry, setScalarField, + uuidComment, } = require('./spm-pbxproj'); -const {makeLogger, remotePackageConfig} = require('./spm-utils'); +const { + isValidScriptPhaseId, + isValidScriptPhaseName, + makeLogger, + remotePackageConfig, +} = require('./spm-utils'); const fs = require('node:fs'); const path = require('node:path'); /*:: import type { FlavoredFrameworkManifestEntry, + PluginScriptPhase, XcframeworkSlice, } from './spm-types'; */ @@ -72,6 +80,17 @@ const SPM_GENERATED_SOURCES_MANIFEST = path.join( '.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', +); + // 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'; @@ -137,6 +156,9 @@ type BuildSettingChange = { // CocoaPods is deintegrated. Deinit restores the original. replacedScalars?: {[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}; // 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 '""'). @@ -264,30 +286,42 @@ function spmGraphToEntries( return {localRefs, remoteRef, productDeps, buildFiles}; } -// Sync SPM Autolinking: timestamp check + conditional node re-run. Shared by -// the build phase (safety net) and the scheme pre-action (the one that -// actually fires before SPM resolution, so a single build picks up -// dep-graph changes from `npm install`). -// Build a PBXShellScriptBuildPhase entry (the "Sync SPM Autolinking" phase). +/** + * 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, outputPaths?: string} */ = {}, + options /*: {inputPaths?: ?(string | ReadonlyArray), outputPaths?: ?(string | ReadonlyArray), alwaysOutOfDate?: ?boolean, comment?: string} */ = {}, ) /*: {uuid: string, comment: string, fields: {[string]: string}} */ { const empty = '(\n\t\t\t)'; + const pathList = ( + value /*: ?(string | ReadonlyArray) */, + ) /*: string */ => { + if (value == null) { + return empty; + } + return typeof value === 'string' ? value : pbxPathList(value); + }; return { uuid: phaseUUID, - comment: name, + comment: options.comment ?? name, fields: { isa: 'PBXShellScriptBuildPhase', + ...(options.alwaysOutOfDate === true ? {alwaysOutOfDate: '1'} : {}), buildActionMask: '2147483647', files: empty, inputFileListPaths: empty, - inputPaths: options.inputPaths ?? empty, + inputPaths: pathList(options.inputPaths), name: quoteIfNeeded(name), outputFileListPaths: empty, - outputPaths: options.outputPaths ?? empty, + outputPaths: pathList(options.outputPaths), runOnlyForDeploymentPostprocessing: '0', shellPath: '/bin/sh', shellScript: quoteIfNeeded(script), @@ -467,11 +501,126 @@ ${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 */ { + 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 */, + 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} */, + member /*: {uuid: string, comment: string, ...} */, ) /*: string */ { const field = findField(text, target, 'buildPhases'); if (field == null || field.value.includes(member.uuid)) { @@ -486,7 +635,7 @@ function addBuildPhaseAfter( 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} /* ${member.comment} */,`; + const line = `\n${indent}${member.uuid}${uuidComment(member.comment)},`; return text.slice(0, lineEnd) + line + text.slice(lineEnd); } @@ -723,6 +872,18 @@ function escapeXmlAttribute(s /*: string */) /*: string */ { .replace(/'/g, '''); } +// The inverse of escapeXmlAttribute. `&` is expanded LAST so an entity that +// was itself escaped (`<` → `&lt;`) round-trips back to its own text +// rather than to `<`. +function unescapeXmlAttribute(s /*: string */) /*: string */ { + return s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + function generateXcscheme( appName /*: string */, targetUUID /*: string */, @@ -1077,7 +1238,8 @@ function injectSpmIntoPbxproj( hermesCliPath /*: ?string */ = null, generatedSources /*: ReadonlyArray */ = [], flavoredFrameworks /*: ReadonlyArray */ = [], -) /*: {text: string, injectedUuids: Array, createdArrayFields: Array<{container: 'project' | 'target', key: string}>, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}} */ { + scriptPhases /*: ReadonlyArray */ = [], +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { let text = input; const mkUuid = (section /*: string */, id /*: string */) => namespacedUUID(plan.rootUuid, section, id); @@ -1110,10 +1272,7 @@ function injectSpmIntoPbxproj( insertObjects('XCSwiftPackageProductDependency', entries.productDeps); insertObjects('PBXBuildFile', entries.buildFiles); - // Track array fields we CREATE (vs. append to a pre-existing one) so deinit - // can remove the whole field and land byte-identical to the original. - const createdArrayFields /*: Array<{container: 'project' | 'target', key: string}> */ = - []; + const createdArrayFields /*: Array */ = []; // 2. packageReferences on the PBXProject. const pkgRefMembers = [ @@ -1304,9 +1463,14 @@ function injectSpmIntoPbxproj( 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: src.name, + comment: label, fields: { isa: 'PBXFileReference', lastKnownFileType: src.fileType, @@ -1317,17 +1481,14 @@ function injectSpmIntoPbxproj( }); buildFiles.push({ uuid: buildFileUuid, - comment: `${src.name} in Sources`, + comment: inSources, fields: { isa: 'PBXBuildFile', - fileRef: `${fileRefUuid} /* ${src.name} */`, + fileRef: `${fileRefUuid}${uuidComment(label)}`, }, }); - sourcesMembers.push({ - uuid: buildFileUuid, - comment: `${src.name} in Sources`, - }); - groupChildren.push({uuid: fileRefUuid, comment: src.name}); + sourcesMembers.push({uuid: buildFileUuid, comment: inSources}); + groupChildren.push({uuid: fileRefUuid, comment: label}); } insertObjects('PBXFileReference', fileRefs); insertObjects('PBXBuildFile', buildFiles); @@ -1387,12 +1548,87 @@ function injectSpmIntoPbxproj( } } + // 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 */ = []; + 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', + ]) { + 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, buildSettingChanges, generatedSourceUuids, + scriptPhaseUuids, }; } @@ -1837,6 +2073,86 @@ function readGeneratedSourcesManifest( return out; } +const nonEmptyStrings = (value /*: unknown */) /*: ?Array */ => + Array.isArray(value) + ? value.filter(p => typeof p === 'string' && p.length > 0) + : null; + +/** + * Read the plugin script-phases manifest at + * `/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 */ { + 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 */ = []; + 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` @@ -1844,7 +2160,7 @@ function readGeneratedSourcesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array, ...} */ { +) /*: ?{generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -1899,6 +2215,39 @@ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { return typeof override === 'string' && override.length > 0 ? override : 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 */, + current /*: ReadonlyArray */, +) /*: Array */ { + 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; +} + /** * Add SPM packages to a user's EXISTING xcodeproj in place. Returns * {status: 'injected', target} on success, or {status: 'refused', reason} @@ -1924,6 +2273,7 @@ function injectSpmIntoExistingXcodeproj( const remote = remotePackageConfig(appRoot); const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot); const generatedSources = readGeneratedSourcesManifest(appRoot); + const scriptPhases = readScriptPhasesManifest(appRoot); const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; const prevMarker = readMarker(xcodeprojPath); @@ -1953,6 +2303,15 @@ function injectSpmIntoExistingXcodeproj( 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]); + } + } // Re-apply generated settings from a clean recorded baseline. This removes // linker entries for plugin frameworks that disappeared and keeps the new // marker a complete inverse after an idempotent update. @@ -1973,6 +2332,7 @@ function injectSpmIntoExistingXcodeproj( createdArrayFields, buildSettingChanges, generatedSourceUuids, + scriptPhaseUuids, } = injectSpmIntoPbxproj( base, { @@ -1987,6 +2347,7 @@ function injectSpmIntoExistingXcodeproj( hermesCliPath, generatedSources, flavoredFrameworks, + scriptPhases, ); const changed = writeIfChanged(pbxprojPath, text); @@ -2032,15 +2393,25 @@ function injectSpmIntoExistingXcodeproj( target: plan.target.name, targetUuid: plan.target.uuid, injectedUuids: Array.from(new Set(injectedUuids)).sort(), - createdArrayFields, + createdArrayFields: mergeCreatedArrayFields( + prevMarker?.createdArrayFields ?? [], + createdArrayFields, + ), buildSettingChanges, // 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, artifactsVersionOverride, scheme: { file: schemeResult.file, - created: schemeResult.status === 'created', + // Sticky — see mergeCreatedArrayFields for why a later sync cannot + // observe this for itself. + created: + schemeResult.status === 'created' || + prevMarker?.scheme?.created === true, }, }, null, @@ -2052,6 +2423,52 @@ function injectSpmIntoExistingXcodeproj( return {status: 'injected', target: plan.target.name}; } +/** The sync pre-action's script, unescaped, or null when the scheme has none. */ +function schemePreActionScript(xml /*: string */) /*: ?string */ { + const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); + if (titleIdx < 0) { + return null; + } + const marker = 'scriptText = "'; + const start = xml.indexOf(marker, titleIdx); + if (start < 0) { + return null; + } + const valueStart = start + marker.length; + // escapeXmlAttribute maps a literal `"` to `"`, so the next `"` is always + // the closing delimiter. + const valueEnd = xml.indexOf('"', valueStart); + return valueEnd < 0 + ? null + : unescapeXmlAttribute(xml.slice(valueStart, 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 */ { + const script = schemePreActionScript(xml); + return ( + script != null && + xml === generateXcscheme(appName, targetUuid, projName, script) + ); +} + /** * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added * to a scheme, and drop the `` wrapper if it is left empty (the @@ -2158,7 +2575,15 @@ function removeSpmInjection( f.container === 'project' ? findProjectObject(text) : findObjectByUuid(text, marker.targetUuid); - if (obj != null) { + 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); } } @@ -2178,7 +2603,8 @@ function removeSpmInjection( writeIfChanged(pbxprojPath, text); log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); - // 3. Scheme: delete it if injection created it, else strip the pre-action. + // 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( @@ -2187,11 +2613,21 @@ function removeSpmInjection( 'xcschemes', scheme.file, ); - if (scheme.created === true) { - fs.rmSync(schemePath, {force: true}); - } else if (fs.existsSync(schemePath)) { + if (fs.existsSync(schemePath)) { const xml = fs.readFileSync(schemePath, 'utf8'); - writeIfChanged(schemePath, removePreActionFromScheme(xml)); + 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)); + } } } @@ -2210,6 +2646,7 @@ module.exports = { ensureStubPackages, buildSpmDependencyGraph, spmGraphToEntries, + buildPhaseOrder, planInjection, injectSpmIntoPbxproj, injectSpmIntoExistingXcodeproj, @@ -2220,5 +2657,6 @@ module.exports = { removePreActionFromScheme, findInjectedXcodeproj, readArtifactsVersionOverride, + readScriptPhasesManifest, SPM_INJECTED_MARKER, }; diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js index 56f27e73ba8d..7c4ece734470 100644 --- a/packages/react-native/scripts/spm/spm-pbxproj.js +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -28,13 +28,55 @@ function generateUUID(seed /*: string */) /*: string */ { } /** - * Escapes a string for OpenStep plist format if needed. + * Escapes a string for OpenStep plist format if needed. A literal CR or tab in + * a quoted value is legal but Xcode rewrites it to its escape on the next save, + * planting a spurious diff in the user's repo — so escape those too (a plugin + * `script` with CRLF line endings is how one gets in). */ function quoteIfNeeded(s /*: string */) /*: string */ { if (/^[a-zA-Z0-9._/]+$/.test(s)) { return s; } - return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; + return `"${s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t')}"`; +} + +/** + * Normalize an UNTRUSTED label (a plugin's script-phase name, say) for use + * inside a pbxproj `/* … *​/` comment. Such a comment is purely cosmetic — Xcode + * regenerates it from the object's own fields — while the text AROUND it is + * scanned by delimiter, so every character that could be read as structure (or + * split the line) becomes a space, and runs of whitespace collapse: + * `findObjectByUuid` takes the first `{` after a UUID as that object's body, + * `removeArrayMembersByUuid` identifies a member line by its trailing comma, and + * `scanToClose` treats a `"` as opening a string. Stable (same input ⇒ same + * output), so re-syncing stays byte-identical. Returns '' when nothing survives; + * callers supply the fallback label. + * + * Comments RN writes to Xcode's OWN convention (`XCLocalSwiftPackageReference + * "build/xcframeworks"`, ` in Sources`) are NOT run through this: they are + * fixed strings, and normalizing them would rewrite bytes Xcode itself produces — + * a spurious diff in the user's repo on their next save. + */ +function commentSafe(s /*: string */) /*: string */ { + return s + .replace(/[{}(),;="*/\s]/g, ' ') + .replace(/ +/g, ' ') + .trim(); +} + +/** + * Format the ` /* … *​/` suffix pbxproj writes after a UUID, omitted entirely for + * an empty label (an uncommented UUID is well-formed — Xcode writes those itself + * for unnamed groups). Labels that did not originate in this repo must already + * have been through `commentSafe`. + */ +function uuidComment(comment /*: ?string */) /*: string */ { + return comment != null && comment !== '' ? ` /* ${comment} */` : ''; } /** @@ -47,11 +89,7 @@ function quoteIfNeeded(s /*: string */) /*: string */ { function serializeEntry( entry /*: {readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...} */, ) /*: string */ { - const comment = - entry.comment != null && entry.comment !== '' - ? ` /* ${entry.comment} */` - : ''; - let out = `\t\t${entry.uuid}${comment} = {`; + let out = `\t\t${entry.uuid}${uuidComment(entry.comment)} = {`; const fieldKeys = Object.keys(entry.fields); if ( fieldKeys.length <= 3 && @@ -343,8 +381,7 @@ function addArrayMembers( const memberIndent = fieldIndent + '\t'; const line = ( m /*: {readonly uuid: string, readonly comment?: ?string, ...} */, - ) => - `${memberIndent}${m.uuid}${m.comment != null && m.comment !== '' ? ` /* ${m.comment} */` : ''},\n`; + ) => `${memberIndent}${m.uuid}${uuidComment(m.comment)},\n`; const field = findField(text, obj, key); if (field != null) { @@ -495,7 +532,8 @@ function removeObjectByUuid( * `uuids` from every `( … )` list in the file (packageReferences, * packageProductDependencies, a Frameworks phase's `files`, buildPhases, …). * Only matches member lines (trailing comma), never the object-definition line - * (which ends in `= {`), so it composes safely with removeObjectByUuid. + * (which ends in `= {`), so it composes safely with removeObjectByUuid — which + * holds because no comment can contain a comma (see commentSafe). */ function removeArrayMembersByUuid( text /*: string */, @@ -628,6 +666,8 @@ module.exports = { namespacedUUID, serializeEntry, quoteIfNeeded, + commentSafe, + uuidComment, // Surgical-edit toolkit (in-place injection): scanString, scanToClose, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index 75d0ba26b6aa..ba290ee05de8 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -219,6 +219,21 @@ export type PluginProductDep = {name: string, package: string}; // in the codegen package so it compiles. export type PluginGeneratedSource = {path: string}; +// A build-time shell phase for the app target — SwiftPM's missing analog of +// CocoaPods' `script_phase`. `id` is the stable ledger key and deterministic +// UUID seed (charset /^[@A-Za-z0-9_./-]+$/, so a scoped npm name works; `:` is +// excluded because the seed is `plugin:`); `position` is normalized to 'end' +// by invokePlugins so consumers never re-derive the default. +export type PluginScriptPhase = { + id: string, + name: string, + script: string, + position: 'beforeCompile' | 'end', + inputPaths?: Array, + outputPaths?: Array, + alwaysOutOfDate?: boolean, +}; + // A plugin-declared dynamic XCFramework pair. RN validates both paths, stages // immutable app-local slots, and links/embeds the selected framework outside // SwiftPM. @@ -302,6 +317,10 @@ export type PluginResult = { // for staleness, e.g. the plugin dep's own `Package.swift` and per-module // manifests. Folded into `.spm-sync-watch-paths` by main(). watchPaths: Array, + // Build-time shell phases for the app target, recorded to + // `.spm-plugin-script-phases.json` by main() and injected by `spm add`/ + // `update`. + scriptPhases: Array, }; export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ @@ -310,6 +329,11 @@ export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ generatedSources?: Array, flavoredFrameworks?: Array, watchPaths?: Array, + // `position` may be omitted here; invokePlugins normalizes it to 'end'. + scriptPhases?: Array<{ + ...PluginScriptPhase, + position?: 'beforeCompile' | 'end', + }>, }; export type DiscoveredPlugin = { diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js index 89be6193f865..68a70b20133d 100644 --- a/packages/react-native/scripts/spm/spm-utils.js +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -624,6 +624,47 @@ function runCodegenAndInstallTemplate( } } +// --------------------------------------------------------------------------- +// Autolinking-plugin script phases — the shared validation rules. Two gates +// apply them with different POLICIES (invokePlugins throws, the injector's +// sidecar reader skips the entry), so only the rules live here. See +// __doc__/spm-autolinking-plugins.md for the reasoning. +// --------------------------------------------------------------------------- + +// `@` and `/` are admitted so a package can use its own scoped npm name +// (`@expo/log-box`). `:` is not: the id is hashed into the UUID seed as +// `plugin:`, and keeping the separator out of the id keeps that seed +// unambiguous. +const SCRIPT_PHASE_ID_PATTERN = /^[@A-Za-z0-9_./-]+$/; + +// `ledger.__proto__ = uuid` sets the prototype instead of an own property, so a +// phase with one of these ids would look recorded, vanish through +// JSON.stringify, and never be removable by `deinit`. +const RESERVED_SCRIPT_PHASE_IDS = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + +function isValidScriptPhaseId(value /*: unknown */) /*: boolean */ { + return ( + typeof value === 'string' && + SCRIPT_PHASE_ID_PATTERN.test(value) && + !RESERVED_SCRIPT_PHASE_IDS.has(value) + ); +} + +/** + * The name reaches the project file twice: verbatim in the escaped `name` field + * Xcode displays, and normalized (spm-pbxproj's commentSafe) in the cosmetic + * `/* … *​/` comments. Both are safe for any single-line string, so the only + * thing left to refuse is a line break — which no Xcode phase display name can + * carry anyway. + */ +function isValidScriptPhaseName(value /*: unknown */) /*: boolean */ { + return typeof value === 'string' && value.length > 0 && !/[\r\n]/.test(value); +} + module.exports = { makeLogger, displayPath, @@ -641,5 +682,7 @@ module.exports = { RemoteVersionError, installSpmCodegenTemplate, runCodegenAndInstallTemplate, + isValidScriptPhaseId, + isValidScriptPhaseName, SCAFFOLDER_MARKER, };