@fireflydb/expo-driver 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>en</string>
7
+ <key>CFBundleExecutable</key>
8
+ <string>Firefly</string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string>com.fireflydb.firefly</string>
11
+ <key>CFBundleInfoDictionaryVersion</key>
12
+ <string>6.0</string>
13
+ <key>CFBundleName</key>
14
+ <string>Firefly</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>FMWK</string>
17
+ <key>CFBundleShortVersionString</key>
18
+ <string>1.0</string>
19
+ <key>CFBundleVersion</key>
20
+ <string>1</string>
21
+ <key>CFBundleSupportedPlatforms</key>
22
+ <array>
23
+ <string>iPhoneOS</string>
24
+ </array>
25
+ <key>DTPlatformName</key>
26
+ <string>iphoneos</string>
27
+ <key>MinimumOSVersion</key>
28
+ <string>15.1</string>
29
+ </dict>
30
+ </plist>
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>en</string>
7
+ <key>CFBundleExecutable</key>
8
+ <string>Firefly</string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string>com.fireflydb.firefly</string>
11
+ <key>CFBundleInfoDictionaryVersion</key>
12
+ <string>6.0</string>
13
+ <key>CFBundleName</key>
14
+ <string>Firefly</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>FMWK</string>
17
+ <key>CFBundleShortVersionString</key>
18
+ <string>1.0</string>
19
+ <key>CFBundleVersion</key>
20
+ <string>1</string>
21
+ <key>CFBundleSupportedPlatforms</key>
22
+ <array>
23
+ <string>iPhoneSimulator</string>
24
+ </array>
25
+ <key>DTPlatformName</key>
26
+ <string>iphonesimulator</string>
27
+ <key>MinimumOSVersion</key>
28
+ <string>15.1</string>
29
+ </dict>
30
+ </plist>
@@ -0,0 +1,33 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'FireflyClient'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = 'MIT'
10
+ s.author = { 'FireflyDB' => 'noreply@example.com' }
11
+ s.homepage = 'https://github.com/fireflydb/fireflydb'
12
+ s.platforms = { :ios => '15.1' }
13
+ s.swift_version = '5.9'
14
+ s.source = { :git => 'https://github.com/fireflydb/fireflydb.git' }
15
+
16
+ s.dependency 'ExpoModulesCore'
17
+ s.dependency 'ExpoSQLite'
18
+
19
+ s.source_files = ['*.swift', '_shared/*.{cpp,h}']
20
+
21
+ # Firefly.xcframework wraps a proper Firefly.framework for each iOS slice
22
+ # (device-arm64 and simulator-universal). CocoaPods + Xcode embed the right
23
+ # framework into <App>.app/Frameworks/Firefly.framework/Firefly and re-sign
24
+ # at archive time. See packages/expo/scripts/build-libfirefly.sh.
25
+ s.vendored_frameworks = 'Firefly.xcframework'
26
+
27
+ s.pod_target_xcconfig = {
28
+ 'DEFINES_MODULE' => 'YES',
29
+ 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17',
30
+ 'CLANG_CXX_LIBRARY' => 'libc++',
31
+ 'OTHER_CPLUSPLUSFLAGS' => '-fvisibility=hidden'
32
+ }
33
+ end
@@ -0,0 +1,193 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ // MARK: - Shared C++ listener registry bindings
5
+ //
6
+ // Listener registration goes through the cross-platform registry in
7
+ // `cpp/firefly_listener.{h,cpp}` (linked into this pod via the _shared
8
+ // symlink configured in FireflyClient.podspec). The registry owns the
9
+ // libfirefly C-ABI lifetime so the iOS side only has to forward blobs
10
+ // to JS.
11
+ //
12
+ // We use an `Int64` handle as the stable identifier between Swift and
13
+ // the C++ registry. The dispatcher receives that handle on every fire
14
+ // and looks up the live module reference in `swiftRegistry` below; a
15
+ // lookup miss after `removeChangeListener` is the documented "stale
16
+ // fire" path and a silent no-op — this is how we honor the libfirefly
17
+ // contract requirement that user_data outlive
18
+ // `firefly_remove_change_listener` calls without freeing any heap
19
+ // object the C-side might dereference.
20
+
21
+ private typealias FireflyDispatchFn = @convention(c) (
22
+ Int64, UnsafePointer<UInt8>?, Int
23
+ ) -> Void
24
+
25
+ @_silgen_name("firefly_listener_register")
26
+ private func firefly_listener_register(
27
+ _ path: UnsafePointer<CChar>?,
28
+ _ handle: Int64,
29
+ _ dispatch: FireflyDispatchFn?
30
+ ) -> UInt64
31
+
32
+ @_silgen_name("firefly_listener_unregister")
33
+ private func firefly_listener_unregister(_ handle: Int64)
34
+
35
+ // MARK: - Swift-side dispatch table
36
+ //
37
+ // The C++ thunk hands us only the integer handle. We keep a static
38
+ // `[Int64: WeakModule]` so a callback can find the right Expo module
39
+ // instance to fire `sendEvent` on. The map entry is just a weak ref +
40
+ // bookkeeping — there's no heap object whose deallocation could race
41
+ // with an in-flight fire. Hot-reload re-instantiates the module: the
42
+ // new instance registers fresh handles and the old (gone) entries
43
+ // drain out as the C++ registry unregisters them on `OnDestroy`.
44
+
45
+ private final class WeakModule {
46
+ weak var module: FireflyClientModule?
47
+ init(_ module: FireflyClientModule) { self.module = module }
48
+ }
49
+
50
+ private let swiftRegistryLock = NSLock()
51
+ private var swiftRegistry: [Int64: WeakModule] = [:]
52
+
53
+ private let fireflyDispatch: FireflyDispatchFn = { handle, blobPtr, blobLen in
54
+ guard let blobPtr = blobPtr else { return }
55
+
56
+ swiftRegistryLock.lock()
57
+ let weakBox = swiftRegistry[handle]
58
+ swiftRegistryLock.unlock()
59
+
60
+ guard let module = weakBox?.module else { return }
61
+
62
+ // Copy bytes — the buffer is only valid for the duration of this call.
63
+ let blob = Data(bytes: blobPtr, count: blobLen)
64
+ // sendEvent is safe to call from any thread; ExpoModulesCore marshals
65
+ // onto the JS thread internally.
66
+ module.sendEvent("FireflyChange", [
67
+ "id": NSNumber(value: handle),
68
+ "blob": blob,
69
+ ])
70
+ }
71
+
72
+ private let nextHandleLock = NSLock()
73
+ private var nextHandleCounter: Int64 = 1
74
+ private func mintHandle() -> Int64 {
75
+ nextHandleLock.lock()
76
+ defer { nextHandleLock.unlock() }
77
+ let h = nextHandleCounter
78
+ nextHandleCounter += 1
79
+ return h
80
+ }
81
+
82
+ public class FireflyClientModule: Module {
83
+ // Set of handles registered against this module instance. Used on
84
+ // teardown to unregister everything still in flight.
85
+ private var liveHandles: Set<Int64> = []
86
+ private let liveHandlesLock = NSLock()
87
+
88
+ public func definition() -> ModuleDefinition {
89
+ Name("FireflyClient")
90
+
91
+ Events("FireflyChange")
92
+
93
+ Function("getLibraryPath") { () -> String in
94
+ // Firefly.framework is embedded by Xcode into <App>.app/Frameworks/
95
+ // Firefly.framework/Firefly (the binary has no extension since that's
96
+ // the standard framework layout). We return the path to that binary.
97
+ //
98
+ // SQLite's sqlite3_load_extension auto-appends ".dylib" when the file
99
+ // at the given path doesn't exist; since the framework binary DOES
100
+ // exist at this exact path, dlopen opens it directly without any
101
+ // suffix mangling.
102
+ let frameworkBinary = Bundle.main.bundleURL
103
+ .appendingPathComponent("Frameworks/Firefly.framework/Firefly")
104
+ .path
105
+ if FileManager.default.fileExists(atPath: frameworkBinary) {
106
+ return frameworkBinary
107
+ }
108
+ throw NSError(
109
+ domain: "FireflyClient",
110
+ code: 1,
111
+ userInfo: [NSLocalizedDescriptionKey: "Firefly.framework not found at \(frameworkBinary). Did the host app rebuild after pod install?"]
112
+ )
113
+ }
114
+
115
+ Function("getEntryPoint") { () -> String in
116
+ return "sqlite3_firefly_init"
117
+ }
118
+
119
+ Constants([
120
+ "entryPoint": "sqlite3_firefly_init"
121
+ ])
122
+
123
+ // Register a change listener against the SQLite database file at
124
+ // `path`. The shared C++ registry tracks the rust-side listener id;
125
+ // we just hand it a freshly-minted handle and a weak ref to this
126
+ // module. Throws if the registry rejects (in-memory DB, bad path).
127
+ Function("addChangeListener") { (path: String) -> Double in
128
+ let handle = mintHandle()
129
+
130
+ swiftRegistryLock.lock()
131
+ swiftRegistry[handle] = WeakModule(self)
132
+ swiftRegistryLock.unlock()
133
+
134
+ let rustId: UInt64 = path.withCString { cstr in
135
+ firefly_listener_register(cstr, handle, fireflyDispatch)
136
+ }
137
+ if rustId == 0 {
138
+ swiftRegistryLock.lock()
139
+ swiftRegistry.removeValue(forKey: handle)
140
+ swiftRegistryLock.unlock()
141
+ throw NSError(
142
+ domain: "FireflyClient",
143
+ code: 2,
144
+ userInfo: [NSLocalizedDescriptionKey: "firefly_listener_register rejected path '\(path)' (in-memory DB or invalid path?)"]
145
+ )
146
+ }
147
+
148
+ self.liveHandlesLock.lock()
149
+ self.liveHandles.insert(handle)
150
+ self.liveHandlesLock.unlock()
151
+
152
+ // Return as Double so JS gets a regular Number. handle stays well
153
+ // below 2^53 in any realistic session lifespan.
154
+ return Double(handle)
155
+ }
156
+
157
+ Function("removeChangeListener") { (handleDouble: Double) -> Void in
158
+ let handle = Int64(handleDouble)
159
+
160
+ self.liveHandlesLock.lock()
161
+ let wasLive = self.liveHandles.remove(handle) != nil
162
+ self.liveHandlesLock.unlock()
163
+ guard wasLive else { return }
164
+
165
+ // Tell the C++ registry to drop the entry first; this also calls
166
+ // `firefly_remove_change_listener`. Any in-flight fire that
167
+ // arrived before we erased will dispatch with a stale module
168
+ // weak-ref check; arrivals after the erase silently no-op via
169
+ // lookup miss in the C++ registry.
170
+ firefly_listener_unregister(handle)
171
+
172
+ swiftRegistryLock.lock()
173
+ swiftRegistry.removeValue(forKey: handle)
174
+ swiftRegistryLock.unlock()
175
+ }
176
+
177
+ OnDestroy {
178
+ // Best-effort cleanup if the module is being torn down (e.g. during
179
+ // hot reload). Removes every still-registered listener.
180
+ self.liveHandlesLock.lock()
181
+ let pending = self.liveHandles
182
+ self.liveHandles.removeAll()
183
+ self.liveHandlesLock.unlock()
184
+
185
+ for handle in pending {
186
+ firefly_listener_unregister(handle)
187
+ swiftRegistryLock.lock()
188
+ swiftRegistry.removeValue(forKey: handle)
189
+ swiftRegistryLock.unlock()
190
+ }
191
+ }
192
+ }
193
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@fireflydb/expo-driver",
3
+ "version": "0.0.6",
4
+ "description": "Expo native module + drivers for FireflyDB. Bundles libfirefly and wires it through expo-sqlite, expo-secure-store, and React Native WebSocket.",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "ios",
10
+ "android/build.gradle",
11
+ "android/src",
12
+ "expo-module.config.json",
13
+ "scripts/build-libfirefly.sh"
14
+ ],
15
+ "dependencies": {
16
+ "@fireflydb/core": "0.0.6"
17
+ },
18
+ "peerDependencies": {
19
+ "expo": ">=53.0.0",
20
+ "expo-crypto": ">=14.0.0",
21
+ "expo-modules-core": ">=2.0.0",
22
+ "expo-secure-store": ">=14.0.0",
23
+ "expo-sqlite": ">=15.0.0",
24
+ "react-native": ">=0.76.0"
25
+ },
26
+ "devDependencies": {
27
+ "expo": "^55.0.0",
28
+ "expo-crypto": "^55.0.0",
29
+ "expo-modules-core": "^55.0.0",
30
+ "expo-secure-store": "^55.0.0",
31
+ "expo-sqlite": "^55.0.0",
32
+ "react": "^19.2.0",
33
+ "react-native": "^0.83.6",
34
+ "typescript": "^5.5.0",
35
+ "vitest": "^2.1.0"
36
+ },
37
+ "keywords": [
38
+ "expo",
39
+ "expo-module",
40
+ "sqlite",
41
+ "crdt",
42
+ "fireflydb"
43
+ ],
44
+ "license": "MIT",
45
+ "scripts": {
46
+ "build:libfirefly": "bash scripts/build-libfirefly.sh",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run --passWithNoTests"
49
+ }
50
+ }
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Cross-compile libfirefly for iOS (fat dylib) and Android (jniLibs).
4
+ #
5
+ # Outputs:
6
+ # ios/libfirefly.dylib (fat: arm64 device + arm64-sim + x86_64-sim)
7
+ # android/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86}/libfirefly.so
8
+ #
9
+ # Usage:
10
+ # bash scripts/build-libfirefly.sh # both platforms
11
+ # bash scripts/build-libfirefly.sh ios # iOS only
12
+ # bash scripts/build-libfirefly.sh android # Android only
13
+ #
14
+ # Requires:
15
+ # - rustup with iOS targets (auto-installed if missing)
16
+ # - Xcode + xcodebuild + lipo (for iOS)
17
+ # - cargo-ndk + Android NDK r27+ (for Android, auto-installed if missing)
18
+ #
19
+ # Source recipes ported from:
20
+ # /Users/maxencehenneron/Documents/Projects/Appsent/Firefly/scripts/build-ios.sh
21
+ # /Users/maxencehenneron/Documents/Projects/Appsent/Firefly/Makefile (ios-* / android-* targets)
22
+
23
+ set -euo pipefail
24
+
25
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ PKG_DIR="$(dirname "$SCRIPT_DIR")"
27
+ SDK_DIR="$(cd "$PKG_DIR/../.." && pwd)"
28
+ REPO_ROOT="$(cd "$SDK_DIR/../.." && pwd)"
29
+ # core/ is part of a Rust workspace at REPO_ROOT, so cargo emits to
30
+ # $REPO_ROOT/target, not $REPO_ROOT/core/target.
31
+ CORE_DIR="$REPO_ROOT/core"
32
+ CARGO_TARGET_DIR="$REPO_ROOT/target"
33
+
34
+ IOS_XCF_OUT="$PKG_DIR/ios/Firefly.xcframework"
35
+ IOS_FRAMEWORK_NAME="Firefly"
36
+ IOS_BUNDLE_ID="com.fireflydb.firefly"
37
+ IOS_DEPLOYMENT_TARGET="15.1"
38
+ ANDROID_JNI_OUT="$PKG_DIR/android/src/main/jniLibs"
39
+
40
+ IOS_TARGETS=(
41
+ "aarch64-apple-ios"
42
+ "aarch64-apple-ios-sim"
43
+ "x86_64-apple-ios"
44
+ )
45
+
46
+ # (rust-target, android-abi)
47
+ ANDROID_TARGETS=(
48
+ "aarch64-linux-android arm64-v8a"
49
+ "armv7-linux-androideabi armeabi-v7a"
50
+ "x86_64-linux-android x86_64"
51
+ "i686-linux-android x86"
52
+ )
53
+
54
+ CARGO_FEATURES="--no-default-features --features loadable_extension"
55
+
56
+ log() { printf '\033[1;34m[build-libfirefly]\033[0m %s\n' "$*"; }
57
+ err() { printf '\033[1;31m[build-libfirefly]\033[0m %s\n' "$*" >&2; }
58
+
59
+ ensure_rustup_target() {
60
+ local target="$1"
61
+ if ! rustup target list --installed | grep -q "^${target}$"; then
62
+ log "installing rust target: $target"
63
+ rustup target add "$target"
64
+ fi
65
+ }
66
+
67
+ build_ios() {
68
+ command -v rustup >/dev/null || { err "rustup not installed"; exit 1; }
69
+ command -v xcodebuild >/dev/null || { err "xcodebuild not installed"; exit 1; }
70
+ command -v lipo >/dev/null || { err "lipo not installed"; exit 1; }
71
+
72
+ log "iOS: ensuring rust targets installed"
73
+ for target in "${IOS_TARGETS[@]}"; do
74
+ ensure_rustup_target "$target"
75
+ done
76
+
77
+ for target in "${IOS_TARGETS[@]}"; do
78
+ log "iOS: building $target"
79
+ (cd "$CORE_DIR" && cargo build --release -p firefly --target "$target" $CARGO_FEATURES)
80
+ done
81
+
82
+ # Build the cargo dylibs (already done above). We then wrap each slice's
83
+ # dylib in a proper Firefly.framework bundle and ship those inside an
84
+ # XCFramework. This is the canonical Apple distribution shape and the only
85
+ # one CocoaPods accepts for dynamic linking via vendored_frameworks.
86
+ #
87
+ # Resulting layout:
88
+ # Firefly.xcframework/
89
+ # ios-arm64/Firefly.framework/{Firefly,Info.plist}
90
+ # ios-arm64_x86_64-simulator/Firefly.framework/{Firefly,Info.plist}
91
+ local stage device_fw sim_fw sim_universal_dylib
92
+ stage="$(mktemp -d)"
93
+ device_fw="$stage/device/${IOS_FRAMEWORK_NAME}.framework"
94
+ sim_fw="$stage/sim/${IOS_FRAMEWORK_NAME}.framework"
95
+ sim_universal_dylib="$stage/sim-universal-libfirefly.dylib"
96
+
97
+ log "iOS: lipo simulator slices (arm64-sim + x86_64)"
98
+ lipo -create \
99
+ "$CARGO_TARGET_DIR/aarch64-apple-ios-sim/release/libfirefly.dylib" \
100
+ "$CARGO_TARGET_DIR/x86_64-apple-ios/release/libfirefly.dylib" \
101
+ -output "$sim_universal_dylib"
102
+
103
+ ios_make_framework "$device_fw" \
104
+ "$CARGO_TARGET_DIR/aarch64-apple-ios/release/libfirefly.dylib" \
105
+ "iPhoneOS" "iphoneos"
106
+ ios_make_framework "$sim_fw" \
107
+ "$sim_universal_dylib" \
108
+ "iPhoneSimulator" "iphonesimulator"
109
+
110
+ log "iOS: assembling Firefly.xcframework"
111
+ rm -rf "$IOS_XCF_OUT"
112
+ mkdir -p "$(dirname "$IOS_XCF_OUT")"
113
+ xcodebuild -create-xcframework \
114
+ -framework "$device_fw" \
115
+ -framework "$sim_fw" \
116
+ -output "$IOS_XCF_OUT" >/dev/null
117
+
118
+ # Clean up artifacts from earlier strategies (fat dylib + two-dylib-resource).
119
+ rm -f "$PKG_DIR/ios/libfirefly.dylib" \
120
+ "$PKG_DIR/ios/libfirefly-device.dylib" \
121
+ "$PKG_DIR/ios/libfirefly-sim.dylib"
122
+
123
+ log "iOS: verifying sqlite3_firefly_init export in both slices"
124
+ for slice in "$IOS_XCF_OUT"/*/${IOS_FRAMEWORK_NAME}.framework/${IOS_FRAMEWORK_NAME}; do
125
+ if ! nm -gU "$slice" 2>/dev/null | grep -q "_sqlite3_firefly_init"; then
126
+ err "iOS: sqlite3_firefly_init not exported in $slice"
127
+ exit 1
128
+ fi
129
+ done
130
+ log "iOS: build complete -> $IOS_XCF_OUT"
131
+ }
132
+
133
+ # Build a single Firefly.framework wrapping a Mach-O dylib. Apple's framework
134
+ # layout for iOS is flat (binary + Info.plist at the framework's root, no
135
+ # Versions/ symlink dance like macOS frameworks).
136
+ ios_make_framework() {
137
+ local fw_path="$1"
138
+ local source_dylib="$2"
139
+ local supported_platform="$3" # iPhoneOS or iPhoneSimulator
140
+ local platform_name="$4" # iphoneos or iphonesimulator
141
+
142
+ rm -rf "$fw_path"
143
+ mkdir -p "$fw_path"
144
+
145
+ # The binary inside a framework is named exactly like the framework, with
146
+ # no extension. The dylib's install_name must use @rpath so the embedded
147
+ # copy resolves at runtime.
148
+ cp "$source_dylib" "$fw_path/$IOS_FRAMEWORK_NAME"
149
+ install_name_tool -id "@rpath/${IOS_FRAMEWORK_NAME}.framework/${IOS_FRAMEWORK_NAME}" \
150
+ "$fw_path/$IOS_FRAMEWORK_NAME"
151
+
152
+ cat > "$fw_path/Info.plist" <<PLIST
153
+ <?xml version="1.0" encoding="UTF-8"?>
154
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
155
+ <plist version="1.0">
156
+ <dict>
157
+ <key>CFBundleDevelopmentRegion</key>
158
+ <string>en</string>
159
+ <key>CFBundleExecutable</key>
160
+ <string>${IOS_FRAMEWORK_NAME}</string>
161
+ <key>CFBundleIdentifier</key>
162
+ <string>${IOS_BUNDLE_ID}</string>
163
+ <key>CFBundleInfoDictionaryVersion</key>
164
+ <string>6.0</string>
165
+ <key>CFBundleName</key>
166
+ <string>${IOS_FRAMEWORK_NAME}</string>
167
+ <key>CFBundlePackageType</key>
168
+ <string>FMWK</string>
169
+ <key>CFBundleShortVersionString</key>
170
+ <string>1.0</string>
171
+ <key>CFBundleVersion</key>
172
+ <string>1</string>
173
+ <key>CFBundleSupportedPlatforms</key>
174
+ <array>
175
+ <string>${supported_platform}</string>
176
+ </array>
177
+ <key>DTPlatformName</key>
178
+ <string>${platform_name}</string>
179
+ <key>MinimumOSVersion</key>
180
+ <string>${IOS_DEPLOYMENT_TARGET}</string>
181
+ </dict>
182
+ </plist>
183
+ PLIST
184
+ }
185
+
186
+ build_android() {
187
+ command -v rustup >/dev/null || { err "rustup not installed"; exit 1; }
188
+ if ! command -v cargo-ndk >/dev/null; then
189
+ log "android: installing cargo-ndk"
190
+ cargo install cargo-ndk
191
+ fi
192
+
193
+ log "android: ensuring rust targets installed"
194
+ for entry in "${ANDROID_TARGETS[@]}"; do
195
+ local rust_target="${entry%% *}"
196
+ ensure_rustup_target "$rust_target"
197
+ done
198
+
199
+ for entry in "${ANDROID_TARGETS[@]}"; do
200
+ local rust_target="${entry%% *}"
201
+ local abi="${entry##* }"
202
+ local out_dir="$ANDROID_JNI_OUT/$abi"
203
+ log "android: building $rust_target -> $abi"
204
+ mkdir -p "$out_dir"
205
+ (cd "$CORE_DIR" && cargo ndk --target "$rust_target" --platform 24 -- \
206
+ build --release -p firefly $CARGO_FEATURES)
207
+ cp "$CARGO_TARGET_DIR/$rust_target/release/libfirefly.so" "$out_dir/libfirefly.so"
208
+ done
209
+
210
+ log "android: build complete; .so files at $ANDROID_JNI_OUT"
211
+ }
212
+
213
+ main() {
214
+ local mode="${1:-all}"
215
+ case "$mode" in
216
+ ios) build_ios ;;
217
+ android) build_android ;;
218
+ all) build_ios; build_android ;;
219
+ *) err "unknown mode: $mode (use ios|android|all)"; exit 1 ;;
220
+ esac
221
+ log "done"
222
+ }
223
+
224
+ main "$@"
@@ -0,0 +1,83 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+ import type { EventSubscription } from 'expo-modules-core';
3
+
4
+ /**
5
+ * Payload of the `FireflyChange` event emitted by the native module each
6
+ * time libfirefly's notify channel signals a local CRDT-relevant write
7
+ * for a database that has at least one registered listener.
8
+ *
9
+ * `blob` is the raw `encode_rowstates` output — the same shape
10
+ * `firefly_changes()` returns and `firefly_apply()` consumes — so a
11
+ * subscription handler can ship it upstream over a WebSocket without
12
+ * touching it.
13
+ */
14
+ export interface FireflyChangePayload {
15
+ /** Listener handle — matches the value returned by `addChangeListener`. */
16
+ id: number;
17
+ /** RowState batch encoded by `crate::codec::encode_rowstates`. */
18
+ blob: Uint8Array;
19
+ }
20
+
21
+ type FireflyChangeListener = (payload: FireflyChangePayload) => void;
22
+
23
+ // Self-contained surface for the native module — we only declare the
24
+ // methods we actually call. Expo Modules' `NativeModule` type alias
25
+ // resolves to a constructor type rather than an instance type, so we
26
+ // skip the inheritance and just spell out the EventEmitter methods we
27
+ // use (addListener / removeAllListeners).
28
+ export type FireflyClientNativeModule = {
29
+ /** Entry-point symbol passed to sqlite3_load_extension. */
30
+ entryPoint: string;
31
+ /**
32
+ * Absolute path to the bundled libfirefly extension, with the platform
33
+ * suffix stripped (SQLite's sqlite3_load_extension re-appends it). Throws
34
+ * if the dylib/so isn't found in the host app's bundle.
35
+ */
36
+ getLibraryPath(): string;
37
+ getEntryPoint(): string;
38
+ /**
39
+ * Register a native change listener against the SQLite file at `path`.
40
+ * Every local CRDT-relevant write to that database fires the
41
+ * `FireflyChange` event with the encoded RowState batch. Returns the
42
+ * listener id; pass it to `removeChangeListener` to stop receiving
43
+ * events. Throws on invalid path / in-memory DB.
44
+ */
45
+ addChangeListener(path: string): number;
46
+ /** Unregister a listener previously returned by `addChangeListener`. */
47
+ removeChangeListener(id: number): void;
48
+ // EventEmitter surface inherited from NativeModule.
49
+ addListener(eventName: 'FireflyChange', listener: FireflyChangeListener): EventSubscription;
50
+ removeAllListeners(eventName: 'FireflyChange'): void;
51
+ };
52
+
53
+ const FireflyClientModule =
54
+ requireNativeModule<FireflyClientNativeModule>('FireflyClient');
55
+
56
+ export default FireflyClientModule;
57
+
58
+ /**
59
+ * Subscribe to local CRDT writes against the database file at `path`.
60
+ * `handler` receives the encoded RowState batch — pass it to your
61
+ * upstream sender (e.g. `ws.send(blob)`). Returns a subscription whose
62
+ * `.remove()` tears down both the JS event listener and the native
63
+ * libfirefly listener.
64
+ *
65
+ * Multiple subscribers per path are supported; each gets its own handle
66
+ * and only sees events tagged with its own id.
67
+ */
68
+ export function subscribeToChanges(
69
+ path: string,
70
+ handler: (blob: Uint8Array) => void,
71
+ ): EventSubscription {
72
+ const id = FireflyClientModule.addChangeListener(path);
73
+ const sub = FireflyClientModule.addListener('FireflyChange', (event) => {
74
+ if (event.id === id) handler(event.blob);
75
+ });
76
+ // Wrap so callers can `.remove()` both halves with one call.
77
+ return {
78
+ remove() {
79
+ sub.remove();
80
+ FireflyClientModule.removeChangeListener(id);
81
+ },
82
+ } as EventSubscription;
83
+ }