From 1ea0540bca8972ab76b4e485694e7d5d8d32bbbf Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 19 Oct 2026 17:47:25 +0200 Subject: [PATCH] feat: spm support for audio worklets --- .../build-compilation-dependencies/SKILL.md | 1 + .../docs/other/swift-package-manager.mdx | 5 ++ .../react-native-audio-worklets/Package.swift | 86 +++++++++++++++++++ .../ios/spm/include/.gitkeep | 2 + .../react-native-audio-worklets/package.json | 11 ++- 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-audio-worklets/Package.swift create mode 100644 packages/react-native-audio-worklets/ios/spm/include/.gitkeep diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 98daadd4f..405fecf3e 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -221,6 +221,7 @@ Keep it in sync with the podspec; how each podspec feature maps: - **SwiftPM's manifest cache ignores files the manifest reads.** `~/Library/Caches/org.swift.swiftpm/manifests/manifest.db` is keyed by `Package.swift` contents + path + tools version + env + SwiftPM version (SwiftPM `ManifestLoader.CacheKey`), so a changed `package.json` section is invisible until `rm -rf ~/Library/Caches/org.swift.swiftpm/manifests` (Xcode ▸ File ▸ Packages ▸ Reset Package Caches) — verified 2026-10-19: an identical manifest kept FFmpeg on after the option flipped off; appending a comment to the manifest made it re-evaluate. When testing option changes, bust the cache or change the manifest text; a fresh DerivedData is not enough. - **Never replace a published asset in place.** SwiftPM remembers each binary target's checksum per URL and refuses a changed artifact ("has changed checksum; this is a potential security risk"), so anyone who already resolved the tag breaks. Repacking always changes checksums (zip timestamps) — publish under a new tag instead. - To test unpublished assets, rewrite a packed copy's binary targets to `binaryTarget(path: "/X.xcframework.zip")` (local zips work) and install it with `npm install --ignore-scripts` (a directory dependency otherwise runs `prepare`). +- **react-native-audio-worklets** has its own `Package.swift` (package/product `ReactNativeAudioWorklets`, the name 0.87 derives; `swiftpmConfig` repeats it and lists `react-native-audio-api` / `react-native-worklets` as `dependencies`). It depends on the sibling autolinked packages `../ReactNativeAudioApi` and `../RNWorklets` (worklets names itself `RNWorklets` via the `spm` block in its `react-native.config.js`; Package.swift since worklets 0.13.0). Our RNAudioAPI target exposes no headers, so the worklets target reaches `` with `.unsafeFlags(["-I", "/../ReactNativeAudioApi/common/cpp"])` — the SwiftPM counterpart of the podspec's `Headers/Public/RNAudioAPI` path (`headerSearchPath` cannot leave the package). It must define `DEBUG`/`NDEBUG` per configuration like RNAudioAPI, for the same reason: React Native's headers change class layouts on `NDEBUG`. (Our own headers only use `NDEBUG` inside inline function bodies, e.g. `BaseAudioContext::assertDriverMutexHeld`, which changes behaviour, not layout.) Verified 2026-10-19: Debug + Release on 0.88.0, Release on 0.87.1, and at runtime on the iOS 27 simulator (`NativeAudioWorkletsModule` resolves, `install()` returns true, AudioContext running). - Verify in throwaway apps on the oldest (0.87.x) and newest supported RN: `@react-native-community/cli init … --version ` (a bare `0.87` finds no template), install an `npm pack --ignore-scripts` tarball under a unique file name, `cd ios && npx react-native spm --deintegrate --yes` (both versions pick `add`/`update` themselves), then `xcodebuild -project .xcodeproj`. --- diff --git a/packages/audiodocs/docs/other/swift-package-manager.mdx b/packages/audiodocs/docs/other/swift-package-manager.mdx index 748694f85..ec74a4d4e 100644 --- a/packages/audiodocs/docs/other/swift-package-manager.mdx +++ b/packages/audiodocs/docs/other/swift-package-manager.mdx @@ -13,6 +13,11 @@ Native iOS apps. To find out what it requires and how to switch an app to it, se other library. There is no extra setup step. Swift Package Manager downloads the prebuilt Opus / Ogg / Vorbis (and, if enabled, FFmpeg) libraries while it resolves packages. +## Worklets + +[`react-native-audio-worklets`](../worklets/introduction.mdx) ships a `Package.swift` as well. It +builds against `react-native-worklets`, which supports Swift Package Manager from version 0.13.0. + ## Build options [`enableFFmpeg` and `disableStaticExternalLibs`](./prebuilt-libraries-support.mdx#setting-the-options) diff --git a/packages/react-native-audio-worklets/Package.swift b/packages/react-native-audio-worklets/Package.swift new file mode 100644 index 000000000..6d799ffc5 --- /dev/null +++ b/packages/react-native-audio-worklets/Package.swift @@ -0,0 +1,86 @@ +// swift-tools-version: 6.0 +import Foundation +import PackageDescription + +let packageDirectory = Context.packageDirectory + +func readPackageVersion() -> String { + let url = URL(fileURLWithPath: packageDirectory).appendingPathComponent("package.json") + guard let data = try? Data(contentsOf: url), + let packageJSON = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let version = packageJSON["version"] as? String + else { + fatalError("[RNAudioWorklets] Cannot read the version from \(url.path)") + } + return version +} + +/// React Native autolinks every library as a sibling under +/// `/ios/build/generated/autolinking/libs/`, which is also where the `.package` +/// dependencies below point. react-native-audio-api's target exposes no headers (a module over +/// every engine header is not wanted), so its `` headers are reached by path, the +/// SwiftPM counterpart of the podspec's `Headers/Public/RNAudioAPI` search path. +let audioAPIHeadersPath = URL(fileURLWithPath: packageDirectory) + .deletingLastPathComponent() + .appendingPathComponent("ReactNativeAudioApi/common/cpp") + .path + +let headerSearchPaths = ["common/cpp", "ios"] +let defines: [(name: String, value: String)] = [ + ("RCT_NEW_ARCH_ENABLED", "1"), + ("AUDIO_WORKLETS_VERSION", readPackageVersion()), +] + +// React Native's headers change class layouts on NDEBUG (without it `DebugStringConvertible` +// gains virtual methods) and the prebuilt React framework is built with NDEBUG in Release, so +// DEBUG/NDEBUG follow the configuration exactly, as in RNAudioAPI and React Native's own targets. +let cSettings: [CSetting] = + headerSearchPaths.map { .headerSearchPath($0) } + defines.map { .define($0.name, to: $0.value) } + [ + .define("DEBUG", to: "1", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + .unsafeFlags(["-I", audioAPIHeadersPath]), + ] +let cxxSettings: [CXXSetting] = + headerSearchPaths.map { .headerSearchPath($0) } + defines.map { .define($0.name, to: $0.value) } + [ + .define("DEBUG", to: "1", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + .unsafeFlags(["-I", audioAPIHeadersPath]), + ] + +let reactHeaders: [Target.Dependency] = [ + .product(name: "ReactHeaders", package: "ReactNative"), + .product(name: "ReactNativeHeaders", package: "ReactNative"), + .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"), + .product(name: "ReactAppHeaders", package: "React-GeneratedCode"), +] + +// The package and product name is what React Native 0.87 derives from the npm name; 0.88 reads it +// from `swiftpmConfig.name` in package.json, which repeats it. +let package = Package( + name: "ReactNativeAudioWorklets", + platforms: [.iOS(.v15)], + products: [ + .library(name: "ReactNativeAudioWorklets", targets: ["RNAudioWorklets"]) + ], + dependencies: [ + .package(name: "ReactNative", path: "../../../../xcframeworks"), + .package(name: "React-GeneratedCode", path: "../../../ios"), + .package(name: "ReactNativeAudioApi", path: "../ReactNativeAudioApi"), + .package(name: "RNWorklets", path: "../RNWorklets"), + ], + targets: [ + .target( + name: "RNAudioWorklets", + dependencies: reactHeaders + [ + .product(name: "ReactNativeAudioApi", package: "ReactNativeAudioApi"), + .product(name: "RNWorklets", package: "RNWorklets"), + ], + path: ".", + sources: ["common/cpp/audioworklets", "ios/audioworklets"], + publicHeadersPath: "ios/spm/include", + cSettings: cSettings, + cxxSettings: cxxSettings + ) + ], + cxxLanguageStandard: .cxx20 +) diff --git a/packages/react-native-audio-worklets/ios/spm/include/.gitkeep b/packages/react-native-audio-worklets/ios/spm/include/.gitkeep new file mode 100644 index 000000000..7b0a3dbb0 --- /dev/null +++ b/packages/react-native-audio-worklets/ios/spm/include/.gitkeep @@ -0,0 +1,2 @@ +// The RNAudioWorklets SwiftPM target exposes no headers, but SwiftPM fails to resolve the package +// when a target's public headers directory is missing, so Package.swift points publicHeadersPath here. diff --git a/packages/react-native-audio-worklets/package.json b/packages/react-native-audio-worklets/package.json index a9ffc13d8..6485eaea9 100644 --- a/packages/react-native-audio-worklets/package.json +++ b/packages/react-native-audio-worklets/package.json @@ -41,11 +41,13 @@ "android/", "CMakeLists.txt", "RNAudioWorklets.podspec", + "Package.swift", "react-native.config.js", "!**/__tests__", "!**/__mocks__", "!**/.*", - "!**/node_modules" + "!**/node_modules", + "ios/spm/include/.gitkeep" ], "scripts": { "test": "jest", @@ -143,6 +145,13 @@ ] ] }, + "swiftpmConfig": { + "name": "ReactNativeAudioWorklets", + "dependencies": [ + "react-native-audio-api", + "react-native-worklets" + ] + }, "codegenConfig": { "name": "rnaudioworklets", "type": "modules",