Skip to content

Commit af4b872

Browse files
peachbitsclaude
andcommitted
Vendor the SDK's SwiftPM-only deps as a pre-built static binary
The modern ZcashLightClientKit SDK pulls grpc-swift (1.24+), SwiftNIO, SwiftProtobuf and SQLite.swift via SwiftPM only -- grpc-swift no longer ships a podspec, so it can't be a CocoaPods dependency. Consuming the SDK via spm_dependency would force the entire host app onto dynamic frameworks. Instead, scripts/buildVendoredDeps.ts pre-builds just those leaf dependencies into one static lib per platform (+ their Swift/C modules) under ios/vendored/, generated by 'npm run update-sources' and shipped in the npm tarball. The ZcashLightClientKit source keeps compiling in-pod exactly as before, and the host app stays on static frameworks. The SQLite C shim is excluded so the host's sqlite3 is used (no duplicate symbols). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2944dd5 commit af4b872

4 files changed

Lines changed: 274 additions & 4 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
/android/build/
33
/ios/libzcashlc.xcframework/
44
/ios/ZCashLightClientKit/
5+
/ios/vendored/
56
/ios/zcashlc.h
67
/lib/
78
/tmp/

react-native-zcash.podspec

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ require "json"
22

33
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
44

5+
# Each bundled C dep module (ios/vendored/cmodules/<Name>/ — headers +
6+
# module.modulemap) becomes one relative clang include path, so the in-pod
7+
# ZcashLightClientKit source can resolve the C modules that the pre-built Swift
8+
# dependency modules (SwiftNIO / GRPC) import.
9+
cmodule_flags = Dir.glob(File.join(__dir__, "ios/vendored/cmodules/*"))
10+
.select { |p| File.directory?(p) }
11+
.map { |p| "-Xcc -I\"$(PODS_TARGET_SRCROOT)/ios/vendored/cmodules/#{File.basename(p)}\"" }
12+
.join(" ")
13+
514
Pod::Spec.new do |s|
615
s.name = package['name']
716
s.version = package['version']
@@ -10,11 +19,15 @@ Pod::Spec.new do |s|
1019
s.license = package['license']
1120
s.authors = package['author']
1221

13-
s.platform = :ios, "13.0"
22+
s.platform = :ios, "16.0"
1423
s.source = {
1524
:git => "https://github.com/EdgeApp/react-native-zcash.git",
1625
:tag => "v#{s.version}"
1726
}
27+
28+
# The bridge + the vendored ZcashLightClientKit Swift source, compiled in-pod
29+
# as ONE module (so the bridge uses SDK types directly — see copySwift in
30+
# scripts/updateSources.ts).
1831
s.source_files =
1932
"ios/react-native-zcash-Bridging-Header.h",
2033
"ios/RNZcash.m",
@@ -25,10 +38,37 @@ Pod::Spec.new do |s|
2538
"zcash-mainnet" => "ios/ZCashLightClientKit/Resources/checkpoints/mainnet/*.json",
2639
"zcash-testnet" => "ios/ZCashLightClientKit/Resources/checkpoints/testnet/*.json"
2740
}
28-
s.vendored_frameworks = "ios/libzcashlc.xcframework"
2941

3042
s.dependency "MnemonicSwift", "~> 2.2"
31-
s.dependency "gRPC-Swift", "~> 1.8"
32-
s.dependency "SQLite.swift/standalone", "~> 0.14"
3343
s.dependency "React-Core"
44+
45+
# The Rust core (a binaryTarget on the SDK's GitHub release):
46+
s.vendored_frameworks = "ios/libzcashlc.xcframework"
47+
48+
# ---------------------------------------------------------------------------
49+
# The SDK's SwiftPM-only dependencies (grpc-swift, SwiftNIO, SwiftProtobuf,
50+
# SQLite.swift) pre-built into one static lib per platform, plus their Swift
51+
# and C modules. grpc-swift 1.24+ ships SwiftPM-only with no podspec, so these
52+
# can no longer be CocoaPods `dependency`s; vendoring them as a static binary
53+
# keeps the host app on STATIC frameworks (consuming the SDK via
54+
# spm_dependency would force the whole app onto dynamic frameworks).
55+
#
56+
# sqlite3 itself is NOT bundled here (the SQLite.swift C shim is excluded in
57+
# buildVendoredDeps) — the host app provides it (Edge vendors its own sqlite3
58+
# pod), which also avoids duplicate-symbol warnings.
59+
#
60+
# Regenerate ios/vendored/ with `npm run update-sources`
61+
# (scripts/buildVendoredDeps.ts).
62+
# ---------------------------------------------------------------------------
63+
s.preserve_paths = "ios/vendored/**/*"
64+
s.pod_target_xcconfig = {
65+
"SWIFT_INCLUDE_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ios/vendored/modules\"",
66+
"OTHER_SWIFT_FLAGS" => cmodule_flags,
67+
# force_load the matching slice so the deps' Swift type metadata / protocol
68+
# conformances (referenced by the SDK source) aren't dead-stripped:
69+
"OTHER_LDFLAGS[sdk=iphoneos*]" =>
70+
"-force_load \"$(PODS_TARGET_SRCROOT)/ios/vendored/libZcashDeps-ios-arm64.a\"",
71+
"OTHER_LDFLAGS[sdk=iphonesimulator*]" =>
72+
"-force_load \"$(PODS_TARGET_SRCROOT)/ios/vendored/libZcashDeps-ios-arm64-simulator.a\""
73+
}
3474
end

scripts/buildVendoredDeps.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Builds the SwiftPM dependency graph that the vendored ZcashLightClientKit
2+
// source links against — grpc-swift, SwiftNIO, SwiftProtobuf, SQLite.swift and
3+
// their C shims — into ONE static library per platform, plus the Swift
4+
// `.swiftmodule`s and C `module.modulemap`s the in-pod SDK source needs to
5+
// `import` them.
6+
//
7+
// Why this exists: as of the modern SDK, grpc-swift (1.24+) ships SwiftPM-only
8+
// with no podspec, so it can no longer be a CocoaPods `dependency`. Instead of
9+
// forcing the whole host app onto dynamic frameworks (the only way to consume
10+
// the SDK via `spm_dependency`), we pre-build just these leaf dependencies into
11+
// a static binary. The host app stays on static frameworks; the SDK *source*
12+
// keeps compiling in-pod exactly as before (see copySwift in updateSources.ts).
13+
//
14+
// Output (all under ios/vendored/, gitignored, shipped in the npm tarball):
15+
// libZcashDeps-ios-arm64.a - merged static lib, device
16+
// libZcashDeps-ios-arm64-simulator.a - merged static lib, simulator
17+
// modules/<Module>.swiftmodule - Swift dep modules (device + sim arches)
18+
// cmodules/<Module>/ - C dep modules (headers + module.modulemap)
19+
//
20+
// Per-platform .a (rather than one xcframework) so the podspec can -force_load
21+
// the right slice via an `[sdk=...]` condition — force_load preserves the Swift
22+
// type metadata / protocol conformances the SDK source pulls from these deps.
23+
24+
import { execFileSync } from 'child_process'
25+
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'fs'
26+
import { join } from 'path'
27+
28+
// This repo's @types/node predates fs.cpSync/rmSync, and the sibling scripts
29+
// already shell out for file ops, so do the same here.
30+
function rm(path: string): void {
31+
execFileSync('rm', ['-rf', path])
32+
}
33+
function cp(src: string, dest: string): void {
34+
execFileSync('cp', ['-R', src, dest])
35+
}
36+
37+
const root = join(__dirname, '..')
38+
const tmp = join(root, 'tmp')
39+
const sdkClone = join(tmp, 'ZCashLightClientKit')
40+
const wrapper = join(tmp, 'deps-wrapper')
41+
const vendored = join(root, 'ios/vendored')
42+
43+
// Targets that must NOT go into the deps binary:
44+
// - ZcashLightClientKit: compiled in-pod from source, not vendored as a binary
45+
// - ZcashDepsWrapper: our throwaway entry-point target
46+
// - CSQLite/SQLite C shim: the host app already provides sqlite3 (Edge vendors
47+
// its own), so bundling it here double-defines every sqlite3_* symbol
48+
const EXCLUDED_TARGETS = [
49+
'ZcashLightClientKit',
50+
'ZcashDepsWrapper',
51+
'CSQLite',
52+
'SQLite3' // some SQLite.swift versions name the C shim differently
53+
]
54+
55+
const PLATFORMS = [
56+
{ destination: 'generic/platform=iOS', dir: 'ios-arm64', arch: 'arm64' },
57+
{
58+
destination: 'generic/platform=iOS Simulator',
59+
dir: 'ios-arm64-simulator',
60+
arch: 'arm64'
61+
}
62+
]
63+
64+
export function buildVendoredDeps(): void {
65+
console.log('Building vendored SwiftPM dependency binary...')
66+
rm(vendored)
67+
mkdirSync(join(vendored, 'modules'), { recursive: true })
68+
mkdirSync(join(vendored, 'cmodules'), { recursive: true })
69+
70+
writeWrapperPackage()
71+
72+
for (const platform of PLATFORMS) {
73+
console.log(` Compiling deps for ${platform.dir}...`)
74+
const dd = join(tmp, `deps-dd-${platform.dir}`)
75+
loud(wrapper, [
76+
'xcodebuild',
77+
'-scheme',
78+
'ZcashDepsWrapper',
79+
'-destination',
80+
platform.destination,
81+
'-derivedDataPath',
82+
dd,
83+
'BUILD_LIBRARY_FOR_DISTRIBUTION=NO',
84+
'ONLY_ACTIVE_ARCH=YES',
85+
'SKIP_INSTALL=NO',
86+
'build'
87+
])
88+
89+
mergeDeps(dd, platform.arch, platform.dir)
90+
harvestModules(dd, platform.arch)
91+
}
92+
console.log('Vendored deps built.')
93+
}
94+
95+
// A throwaway SwiftPM package that depends on the SDK so SwiftPM resolves the
96+
// SDK's EXACT dependency versions; we then harvest those compiled deps.
97+
function writeWrapperPackage(): void {
98+
rm(wrapper)
99+
mkdirSync(join(wrapper, 'Sources/ZcashDepsWrapper'), { recursive: true })
100+
writeFileSync(
101+
join(wrapper, 'Package.swift'),
102+
`// swift-tools-version:5.9
103+
import PackageDescription
104+
let package = Package(
105+
name: "ZcashDepsWrapper",
106+
platforms: [.iOS(.v16)],
107+
products: [.library(name: "ZcashDepsWrapper", type: .static, targets: ["ZcashDepsWrapper"])],
108+
dependencies: [.package(path: ${JSON.stringify(sdkClone)})],
109+
targets: [.target(name: "ZcashDepsWrapper", dependencies: [
110+
.product(name: "ZcashLightClientKit", package: "ZCashLightClientKit")
111+
])]
112+
)
113+
`
114+
)
115+
writeFileSync(
116+
join(wrapper, 'Sources/ZcashDepsWrapper/Empty.swift'),
117+
'@_exported import ZcashLightClientKit\n'
118+
)
119+
}
120+
121+
// Merge every dependency target's compiled objects into one static lib, keeping
122+
// each target's objects grouped so cross-target basename collisions don't drop
123+
// symbols. Excludes the SDK, the wrapper, the sqlite C shim, and tests.
124+
function mergeDeps(dd: string, arch: string, dir: string): void {
125+
const objectsRoot = join(dd, 'Build/Intermediates.noindex')
126+
const objects = findObjects(objectsRoot, arch).filter(path => {
127+
if (/IntegrationTests|Benchmarks|Tests\.build|Example/.test(path)) {
128+
return false
129+
}
130+
return !EXCLUDED_TARGETS.some(target => path.includes(`/${target}.build/`))
131+
})
132+
if (objects.length === 0) {
133+
throw new Error(`No dependency objects found under ${objectsRoot}`)
134+
}
135+
const listFile = join(tmp, `deps-objects-${dir}.txt`)
136+
writeFileSync(listFile, objects.join('\n'))
137+
const out = join(vendored, `libZcashDeps-${dir}.a`)
138+
rm(out)
139+
loud(tmp, ['libtool', '-static', '-o', out, '-filelist', listFile])
140+
}
141+
142+
function findObjects(base: string, arch: string): string[] {
143+
const out: string[] = []
144+
const walk = (dir: string): void => {
145+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
146+
const full = join(dir, entry.name)
147+
if (entry.isDirectory()) walk(full)
148+
else if (
149+
entry.name.endsWith('.o') &&
150+
dir.endsWith(`Objects-normal/${arch}`)
151+
) {
152+
out.push(full)
153+
}
154+
}
155+
}
156+
walk(base)
157+
return out
158+
}
159+
160+
// Copy the dep Swift `.swiftmodule`s (merging device + simulator arch slices)
161+
// and the C modules (headers + module.modulemap) the SDK source imports.
162+
function harvestModules(dd: string, arch: string): void {
163+
const products = findProductsDir(dd)
164+
// Swift modules: copy each <Module>.swiftmodule, unioning arch slices across
165+
// the per-platform builds (each build contributes its own arch's slice).
166+
for (const entry of readdirSync(products)) {
167+
if (!entry.endsWith('.swiftmodule')) continue
168+
const name = entry.replace('.swiftmodule', '')
169+
if (EXCLUDED_TARGETS.includes(name)) continue
170+
const src = join(products, entry)
171+
const dest = join(vendored, 'modules', entry)
172+
if (statSync(src).isDirectory()) {
173+
// Union the per-platform arch slices into one .swiftmodule bundle.
174+
mkdirSync(dest, { recursive: true })
175+
cp(`${src}/.`, dest)
176+
} else if (!existsSync(dest)) {
177+
cp(src, dest)
178+
}
179+
}
180+
// C modules: each compiled C target maps to a checkout include/ dir with a
181+
// module.modulemap (synthesize a simple umbrella one if SwiftPM generated it).
182+
const checkouts = join(dd, 'SourcePackages/checkouts')
183+
if (!existsSync(checkouts)) return
184+
for (const obj of readdirSync(products)) {
185+
if (!obj.endsWith('.o')) continue
186+
const mod = obj.replace('.o', '')
187+
if (existsSync(join(vendored, 'cmodules', mod))) continue
188+
const inc = findInclude(checkouts, mod)
189+
if (inc == null) continue
190+
const dest = join(vendored, 'cmodules', mod)
191+
cp(inc, dest)
192+
if (!existsSync(join(dest, 'module.modulemap'))) {
193+
writeFileSync(
194+
join(dest, 'module.modulemap'),
195+
`module ${mod} {\n umbrella "."\n export *\n}\n`
196+
)
197+
}
198+
}
199+
}
200+
201+
function findProductsDir(dd: string): string {
202+
const base = join(dd, 'Build/Products')
203+
const entry = readdirSync(base).find(name => name.startsWith('Debug-'))
204+
if (entry == null) throw new Error(`No Products dir under ${base}`)
205+
return join(base, entry)
206+
}
207+
208+
function findInclude(checkouts: string, mod: string): string | undefined {
209+
for (const pkg of readdirSync(checkouts)) {
210+
const inc = join(checkouts, pkg, 'Sources', mod, 'include')
211+
if (existsSync(inc) && readdirSync(inc).some(f => f.endsWith('.h'))) {
212+
return inc
213+
}
214+
}
215+
return undefined
216+
}
217+
218+
function loud(cwd: string, argv: string[]): void {
219+
execFileSync(argv[0], argv.slice(1), {
220+
cwd,
221+
stdio: 'inherit',
222+
encoding: 'utf8'
223+
})
224+
}

scripts/updateSources.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { deepList, justFiles, makeNodeDisklet, navigateDisklet } from 'disklet'
99
import { existsSync, mkdirSync, readFileSync } from 'fs'
1010
import { join } from 'path'
1111

12+
import { buildVendoredDeps } from './buildVendoredDeps'
1213
import { copyCheckpoints } from './copyCheckpoints'
1314

1415
const disklet = makeNodeDisklet(join(__dirname, '../'))
@@ -20,6 +21,10 @@ async function main(): Promise<void> {
2021
await rebuildXcframework()
2122
await copySwift()
2223
await copyCheckpoints(disklet)
24+
// grpc-swift (1.24+) and SwiftNIO are SwiftPM-only with no podspec, so the
25+
// deps the vendored SDK source links against are pre-built into a static
26+
// binary instead of being CocoaPods dependencies.
27+
buildVendoredDeps()
2328
}
2429

2530
// The Swift SDK version to vendor. The matching libzcashlc.xcframework is

0 commit comments

Comments
 (0)