diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 4a8460d1a3a7..fbbbddf43cb1 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -608,12 +608,23 @@ deprecated `spm` settings in it are then ignored rather than silently applied. ## Self-managed community packages The autolinker references a community library that ships its own `Package.swift` -directly, instead of wrapping it. SwiftPM derives package identity from the path -basename, and several libs may put their manifest inside an `ios/` subdir. To -keep identity unique, each self-managed dep is exposed through a uniquely-named -symlink at `build/generated/autolinking/libs//`. The aggregator -`Package.swift` references that path, so two libs that both ship -`/ios/Package.swift` never collide on identity `"ios"`. +directly, instead of wrapping it. It looks for the manifest at the library root, +then in `ios/`, then in `apple/`, and uses the first one it did not generate +itself. A manifest in `ios/` or `apple/` keeps SwiftPM artifacts (`.build/`, +`.swiftpm/`, `Package.resolved`) out of the npm package root. + +For a self-managed library, `swiftpmConfig.name` must equal the name of a +product its `Package.swift` declares. The aggregator depends on the library as +`.product(name: "", package: "")`, and SwiftPM fails to +resolve the package graph if no product has that name. A manifest in `ios/` or +`apple/` follows the same rule as one at the root. + +SwiftPM derives package identity from the path basename, and several libs may +put their manifest inside an `ios/` or `apple/` subdir. To keep identity unique, +each self-managed dep is exposed through a uniquely-named symlink at +`build/generated/autolinking/libs//`. The aggregator `Package.swift` +references that path, so two libs that both ship `/ios/Package.swift` never +collide on identity `"ios"`. The `libs/` directory is not recreated on each autolinker run. An alias that does not change keeps its inode, because Xcode holds each one as a loaded 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 1c5b22f08e9f..96ba747dc8f0 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 @@ -973,9 +973,10 @@ describe('generateSynthPackageSwift (spm.name override)', () => { }); // --------------------------------------------------------------------------- -// findSelfManagedPackageDir — detects hand-authored Package.swift at either -// the dep root or under ios/. The nested layout lets community libraries -// keep their npm-package root free of SPM artifacts (.build/, .swiftpm/). +// findSelfManagedPackageDir — detects hand-authored Package.swift at the dep +// root, under ios/, or under apple/. The nested layouts let community +// libraries keep their npm-package root free of SPM artifacts (.build/, +// .swiftpm/). // --------------------------------------------------------------------------- describe('findSelfManagedPackageDir', () => { @@ -1046,6 +1047,75 @@ describe('findSelfManagedPackageDir', () => { ); expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios')); }); + + it('returns /apple when only an apple/ manifest exists and lacks the AUTOGEN marker', () => { + // Libraries that share sources across iOS, macOS, tvOS and visionOS often + // keep them (and their manifest) in apple/ instead of ios/. + fs.mkdirSync(path.join(depRoot, 'apple')); + fs.writeFileSync( + path.join(depRoot, 'apple', 'Package.swift'), + '// swift-tools-version: 6.0\n// Hand-authored apple/ manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe( + path.join(depRoot, 'apple'), + ); + }); + + it('returns null when the apple/ manifest carries the AUTOGEN marker', () => { + fs.mkdirSync(path.join(depRoot, 'apple')); + fs.writeFileSync( + path.join(depRoot, 'apple', 'Package.swift'), + AUTOGEN_MARKER + '\n// synth wrapper content\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(null); + }); + + it('prefers the root manifest over an apple/ manifest', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// Root manifest.\n', + ); + fs.mkdirSync(path.join(depRoot, 'apple')); + fs.writeFileSync( + path.join(depRoot, 'apple', 'Package.swift'), + '// apple/ manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot); + }); + + it('prefers ios/ over apple/ when both ship a hand-authored manifest', () => { + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// ios/ manifest.\n', + ); + fs.mkdirSync(path.join(depRoot, 'apple')); + fs.writeFileSync( + path.join(depRoot, 'apple', 'Package.swift'), + '// apple/ manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios')); + }); + + it('falls back to apple/ when the root and ios/ manifests are autolinker-generated', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + AUTOGEN_MARKER + '\n// stale synth\n', + ); + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + AUTOGEN_MARKER + '\n// stale synth\n', + ); + fs.mkdirSync(path.join(depRoot, 'apple')); + fs.writeFileSync( + path.join(depRoot, 'apple', 'Package.swift'), + '// Hand-authored apple/ manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe( + path.join(depRoot, 'apple'), + ); + }); }); // --------------------------------------------------------------------------- @@ -2176,6 +2246,53 @@ describe('main() — libs/ symlinks for self-managed deps', () => { app.sync(); expect(fs.readdirSync(app.libsDir)).toEqual(['ReactNativeFoo']); }); + + it('links a dep whose hand-authored manifest lives in apple/', () => { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-libs-apple-')), + ); + 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'}), + ); + + // Manifest and sources live in apple/, nothing at the dep root. + const depDir = path.join(appRoot, 'node_modules', 'react-native-baz'); + fs.mkdirSync(path.join(depDir, 'apple'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'apple', 'Package.swift'), + '// swift-tools-version:5.9\n// hand-authored\n', + ); + fs.writeFileSync(path.join(depDir, 'apple', 'Baz.swift'), '// src\n'); + + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-baz': {root: depDir, platforms: {ios: {}}}, + }, + }), + ); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + // Referenced as self-managed: the alias points at apple/, and no synth + // wrapper is written for it. + expect( + fs.realpathSync(path.join(autolinkDir, 'libs', 'ReactNativeBaz')), + ).toBe(path.join(depDir, 'apple')); + expect( + fs.existsSync(path.join(autolinkDir, 'packages', 'ReactNativeBaz')), + ).toBe(false); + expect( + fs.readFileSync(path.join(autolinkDir, 'Package.swift'), 'utf8'), + ).toContain('path: "libs/ReactNativeBaz"'); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index e05e07e011d3..144d2c42a649 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -968,6 +968,28 @@ end ).toBe(nestedContent); }); + it('refuses to scaffold when a nested apple/Package.swift exists without markers', () => { + makePodspec(); + // Same as the ios/ case, for libraries that keep sources shared across + // Apple platforms (and their manifest) in apple/. + fs.mkdirSync(path.join(depRoot, 'apple'), {recursive: true}); + const nestedContent = + '// swift-tools-version: 6.0\n// Hand-written apple/ manifest.\n'; + fs.writeFileSync( + path.join(depRoot, 'apple', 'Package.swift'), + nestedContent, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-self-managed'); + expect(result.reason).toContain('apple/Package.swift'); + // Root stayed clean + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + // Nested file untouched + expect( + fs.readFileSync(path.join(depRoot, 'apple', 'Package.swift'), 'utf8'), + ).toBe(nestedContent); + }); + it('refuses to overwrite a Package.swift carrying the autolinker AUTOGEN_MARKER', () => { makePodspec(); fs.writeFileSync( diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index 8bce8bce0f21..51b04bdc159b 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -75,6 +75,7 @@ const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { AUTOLINKED_PACKAGE_NAME, CXX_API_DEFINES, + NESTED_PACKAGE_SWIFT_DIRS, REACT_CODEGEN_PACKAGE_NAME, REACT_CODEGEN_PRODUCTS, REACT_NATIVE_PACKAGE_NAME, @@ -462,19 +463,26 @@ const AUTOGEN_MARKER = * libraries that want to ship a real SPM manifest and have full control * over their target settings. * - * Two layouts are recognized: + * Three layouts are recognized, checked in this order: * 1. /Package.swift — manifest at the npm-package root * 2. /ios/Package.swift — manifest co-located with ObjC * sources, keeping the npm-package * root free of SPM artifacts * (.build/, .swiftpm/, Package.resolved) + * 3. /apple/Package.swift — same as 2, for libraries that keep + * sources shared across Apple + * platforms in apple/ + * + * The first hand-authored manifest wins, so a root manifest shadows a nested + * one, and ios/ shadows apple/. * * Returns the directory that contains the hand-authored manifest, or null * when no candidate exists. That directory is what the aggregator hands to - * SPM as `.package(path:)` — for layout 2 that means `/ios`. + * SPM as `.package(path:)` — for layouts 2 and 3 that means `/ios` or + * `/apple`. */ function findSelfManagedPackageDir(absSource /*: string */) /*: ?string */ { - for (const sub of ['', 'ios']) { + for (const sub of ['', ...NESTED_PACKAGE_SWIFT_DIRS]) { const dir = sub === '' ? absSource : path.join(absSource, sub); try { const content = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8'); @@ -1571,9 +1579,10 @@ function main(argv /*:: ?: Array */) /*: void */ { } const selfManagedDir = findSelfManagedPackageDir(absSource); if (selfManagedDir != null) { - // Record the manifest's actual directory — for the nested layout this - // is /ios, not . SPM resolves `.package(path:)` against that - // directory expecting Package.swift to live alongside. + // Record the manifest's actual directory — for the nested layouts this + // is /ios or /apple, not . SPM resolves + // `.package(path:)` against that directory expecting Package.swift to + // live alongside. selfManagedDirs.set(target.name, selfManagedDir); // If a wrapper exists from a prior synth-mode run (i.e. the dep WAS // autolinker-wrapped, then later transitioned to self-managed via diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index cf7e5879ce2d..7bac2f5fe4a9 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -47,6 +47,7 @@ const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); const {MIN_IOS_VERSION_SUPPORTED} = require('./ios-deployment-target'); const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { + NESTED_PACKAGE_SWIFT_DIRS, REACT_CODEGEN_PACKAGE_NAME, REACT_CODEGEN_PRODUCTS, REACT_NATIVE_PACKAGE_NAME, @@ -914,24 +915,29 @@ function scaffoldPackageSwiftForDep( const pkgSwiftPath = path.join(dep.root, 'Package.swift'); // A self-managed Package.swift may live either at the dep root (legacy - // convention) or inside ios/ (preferred for community RN libs that want - // their npm-package root free of SPM artifacts). If a nested manifest + // convention) or inside ios/ or apple/ (preferred for community RN libs that + // want their npm-package root free of SPM artifacts). If a nested manifest // exists and is user-authored, skip — writing a stray root manifest would // cause the autolinker to prefer the wrong file. - const nestedPkgSwiftPath = path.join(dep.root, 'ios', 'Package.swift'); - if (!fs.existsSync(pkgSwiftPath) && fs.existsSync(nestedPkgSwiftPath)) { - const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8'); - if ( - !nested.includes(AUTOGEN_MARKER) && - !nested.includes(SCAFFOLDER_MARKER) - ) { - return { - depName, - status: 'skipped-self-managed', - reason: - 'Existing ios/Package.swift was not produced by this scaffolder. ' + - 'Leaving it alone.', - }; + if (!fs.existsSync(pkgSwiftPath)) { + for (const sub of NESTED_PACKAGE_SWIFT_DIRS) { + const nestedPkgSwiftPath = path.join(dep.root, sub, 'Package.swift'); + if (!fs.existsSync(nestedPkgSwiftPath)) { + continue; + } + const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8'); + if ( + !nested.includes(AUTOGEN_MARKER) && + !nested.includes(SCAFFOLDER_MARKER) + ) { + return { + depName, + status: 'skipped-self-managed', + reason: + `Existing ${sub}/Package.swift was not produced by this scaffolder. ` + + 'Leaving it alone.', + }; + } } } diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js index 57973f717d58..4f107a823737 100644 --- a/packages/react-native/scripts/spm/spm-utils.js +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -299,6 +299,16 @@ const PER_APP_HEADERS_REL = 'build/generated/ios/ReactAppHeaders'; const SCAFFOLDER_MARKER = '// AUTO-SCAFFOLDED by react-native spm scaffold — safe to edit & commit via patch-package.'; +// Subdirectories of a library where a hand-written Package.swift may live +// instead of the package root, in lookup order (the root is always checked +// first). Both keep SwiftPM artifacts (.build/, .swiftpm/, Package.resolved) +// out of the npm package root: `ios/` sits next to the iOS sources, and +// `apple/` next to sources shared across Apple platforms. +const NESTED_PACKAGE_SWIFT_DIRS /*: ReadonlyArray */ = Object.freeze([ + 'ios', + 'apple', +]); + // Remote SPM package mode (prototype of the GitHub-distribution endgame): // when active, app + libraries all depend on ONE remote package identity // (`.package(url:exact:)`) instead of the local path-based artifacts package @@ -828,4 +838,5 @@ module.exports = { isValidScriptPhaseId, isValidScriptPhaseName, SCAFFOLDER_MARKER, + NESTED_PACKAGE_SWIFT_DIRS, };