diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 039dd729..957e0e9b 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -9,12 +9,16 @@ on: - 'ios/**' - 'common/**' - 'example/ios/**' + - 'Package.swift' + - 'src/__tests__/swiftpm-test.ts' pull_request: paths: - '.github/workflows/ios.yml' - 'ios/**' - 'common/**' - 'example/ios/**' + - 'Package.swift' + - 'src/__tests__/swiftpm-test.ts' jobs: ios-build: runs-on: macos-latest @@ -55,6 +59,8 @@ jobs: bundler-cache: true - name: Install dependencies run: yarn install + - name: Verify SwiftPM manifest + run: yarn jest src/__tests__/swiftpm-test.ts - name: Install example app dependencies run: yarn install working-directory: example diff --git a/.gitignore b/.gitignore index 65008f61..7f62eafc 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,7 @@ lib/ # Expo web-build .expo + +# Swift Package Manager +.build/ +Package.resolved diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..5ed439fa --- /dev/null +++ b/Package.swift @@ -0,0 +1,49 @@ +// swift-tools-version: 6.0 +import PackageDescription + +// Swift Package Manager support for React Native's SwiftPM autolinking +// (react-native >= 0.87). CocoaPods users are unaffected; see the podspec. +// The two React Native packages are resolved by the autolinker relative to +// build/generated/autolinking/libs/ReactNativeSafeAreaContext in the app. +let package = Package( + name: "ReactNativeSafeAreaContext", + platforms: [.iOS(.v15)], + products: [ + .library(name: "ReactNativeSafeAreaContext", targets: ["ReactNativeSafeAreaContext"]), + ], + dependencies: [ + .package(name: "ReactNative", path: "../../../../xcframeworks"), + .package(name: "React-GeneratedCode", path: "../../../ios"), + ], + targets: [ + .target( + name: "ReactNativeSafeAreaContext", + dependencies: [ + .product(name: "ReactHeaders", package: "ReactNative"), + .product(name: "ReactNativeHeaders", package: "ReactNative"), + .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"), + .product(name: "ReactAppHeaders", package: "React-GeneratedCode"), + ], + path: ".", + exclude: ["ios/RNSafeAreaContext.xcodeproj"], + sources: ["ios", "common/cpp"], + publicHeadersPath: "ios", + cSettings: [ + .headerSearchPath("common/cpp"), + .headerSearchPath("ios"), + ], + cxxSettings: [ + .headerSearchPath("common/cpp"), + .headerSearchPath("ios"), + .define("DEBUG", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ], + linkerSettings: [ + .linkedFramework("UIKit"), + .linkedFramework("Foundation"), + .linkedFramework("CoreGraphics"), + ] + ), + ], + cxxLanguageStandard: .cxx20 +) diff --git a/package.json b/package.json index d3889790..f992aa36 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "common", "jest", "*.podspec", + "Package.swift", "react-native.config.js", "!**/__tests__", "!example" diff --git a/src/__tests__/swiftpm-test.ts b/src/__tests__/swiftpm-test.ts new file mode 100644 index 00000000..843f7f97 --- /dev/null +++ b/src/__tests__/swiftpm-test.ts @@ -0,0 +1,199 @@ +// Guards Package.swift and its packaging. React Native's SwiftPM autolinker +// (>= 0.87) consumes the manifest as-is: it derives the product name from the +// npm package name, resolves the React Native packages by relative path, and +// leaves a manifest a library ships itself alone. Drift in any of those makes +// an app fail to resolve or fail to link. +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const MANIFEST = path.join(REPO_ROOT, 'Package.swift'); + +type Dependency = { + nameForTargetDependencyResolutionOnly?: string; + path?: string; + fileSystem?: Dependency[]; +}; + +type Dump = { + toolsVersion: { _version: string }; + cxxLanguageStandard: string | null; + platforms: { platformName: string; version: string }[]; + products: { name: string; targets: string[]; type: { library?: string[] } }[]; + dependencies: Dependency[]; + targets: { + name: string; + path: string; + sources: string[]; + exclude: string[]; + publicHeadersPath: string | null; + dependencies: { product?: unknown[] }[]; + settings: { + tool: string; + kind: Record; + condition?: { config?: string }; + }[]; + }[]; +}; + +function contract(dump: Dump) { + return { + toolsVersion: dump.toolsVersion._version, + cxxLanguageStandard: dump.cxxLanguageStandard, + platforms: dump.platforms.map((each) => [each.platformName, each.version]), + products: dump.products.map((product) => ({ + name: product.name, + targets: product.targets, + library: product.type.library, + })), + dependencies: dump.dependencies + .flatMap((dependency) => dependency.fileSystem ?? [dependency]) + .map((each) => [each.nameForTargetDependencyResolutionOnly, each.path]), + targets: dump.targets.map((target) => ({ + name: target.name, + path: target.path, + sources: target.sources, + exclude: target.exclude, + publicHeadersPath: target.publicHeadersPath, + products: target.dependencies.map((each) => each.product?.slice(0, 2)), + settings: target.settings.flatMap((setting) => + Object.entries(setting.kind).map(([kind, value]) => + [setting.tool, kind, value._0, setting.condition?.config].filter( + (part) => part !== undefined, + ), + ), + ), + })), + }; +} + +let hasSwift = process.platform === 'darwin'; +try { + if (hasSwift) { + execFileSync('xcrun', ['--find', 'swift'], { stdio: 'ignore' }); + } +} catch { + hasSwift = false; +} + +describe('Package.swift', () => { + it('is a hand-written manifest for Swift 6 tools', () => { + const source = fs.readFileSync(MANIFEST, 'utf8'); + expect(source.split('\n')[0]).toBe('// swift-tools-version: 6.0'); + expect(source).not.toMatch(/AUTO-(SCAFFOLDED|GENERATED)/); + }); +}); + +(hasSwift ? describe : describe.skip)( + 'Package.swift evaluated by SwiftPM', + () => { + let scratchPath: string; + let dump: Dump; + + beforeAll(() => { + scratchPath = fs.mkdtempSync(path.join(os.tmpdir(), 'rnsac-spm-')); + dump = JSON.parse( + execFileSync( + 'swift', + ['package', '--scratch-path', scratchPath, 'dump-package'], + { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ), + ) as Dump; + }, 300_000); + + afterAll(() => { + if (scratchPath) { + fs.rmSync(scratchPath, { force: true, recursive: true }); + } + }); + + it('matches the React Native autolinking contract', () => { + expect(contract(dump)).toEqual({ + toolsVersion: '6.0.0', + cxxLanguageStandard: 'c++20', + platforms: [['ios', '15.0']], + products: [ + { + name: 'ReactNativeSafeAreaContext', + targets: ['ReactNativeSafeAreaContext'], + library: ['automatic'], + }, + ], + dependencies: [ + ['ReactNative', path.resolve(REPO_ROOT, '../../../../xcframeworks')], + ['React-GeneratedCode', path.resolve(REPO_ROOT, '../../../ios')], + ], + targets: [ + { + name: 'ReactNativeSafeAreaContext', + path: '.', + sources: ['ios', 'common/cpp'], + exclude: ['ios/RNSafeAreaContext.xcodeproj'], + publicHeadersPath: 'ios', + products: [ + ['ReactHeaders', 'ReactNative'], + ['ReactNativeHeaders', 'ReactNative'], + ['ReactNativeDependenciesHeaders', 'ReactNative'], + ['ReactAppHeaders', 'React-GeneratedCode'], + ], + settings: [ + ['c', 'headerSearchPath', 'common/cpp'], + ['c', 'headerSearchPath', 'ios'], + ['cxx', 'headerSearchPath', 'common/cpp'], + ['cxx', 'headerSearchPath', 'ios'], + ['cxx', 'define', 'DEBUG', 'debug'], + ['cxx', 'define', 'NDEBUG', 'release'], + ['linker', 'linkedFramework', 'UIKit'], + ['linker', 'linkedFramework', 'Foundation'], + ['linker', 'linkedFramework', 'CoreGraphics'], + ], + }, + ], + }); + }); + + it('references only paths that exist', () => { + const referenced = dump.targets.flatMap((target) => + [...target.sources, ...target.exclude].map((entry) => + path.join(REPO_ROOT, target.path, entry), + ), + ); + expect(referenced.filter((entry) => !fs.existsSync(entry))).toEqual([]); + }); + }, +); + +describe('npm package', () => { + it('ships the manifest but no local SwiftPM state', () => { + // npm 10 runs `prepare` (bob build) despite --ignore-scripts; keeping + // scripts in the background keeps their output out of the JSON on stdout. + const packed = execFileSync( + 'npm', + [ + 'pack', + '--dry-run', + '--json', + '--ignore-scripts', + '--foreground-scripts=false', + ], + { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ); + const [tarball] = JSON.parse(packed) as { files: { path: string }[] }[]; + const files = tarball?.files.map((file) => file.path) ?? []; + + expect(files).toEqual( + expect.arrayContaining([ + 'Package.swift', + 'react-native-safe-area-context.podspec', + ]), + ); + expect( + files.filter( + (file) => file === 'Package.resolved' || file.startsWith('.build/'), + ), + ).toEqual([]); + }, 300_000); +});