Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,7 @@ lib/
# Expo
web-build
.expo

# Swift Package Manager
.build/
Package.resolved
49 changes: 49 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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
)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"common",
"jest",
"*.podspec",
"Package.swift",
"react-native.config.js",
"!**/__tests__",
"!example"
Expand Down
199 changes: 199 additions & 0 deletions src/__tests__/swiftpm-test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { _0: unknown }>;
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);
});
Loading