@glassly/crust 0.1.0-dev.1

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.
Files changed (67) hide show
  1. package/README.md +41 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/glassly/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +33 -0
  6. package/android/src/main/java/com/glassly/crust/CrustModule.kt +1074 -0
  7. package/android/src/main/java/com/glassly/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/glassly/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/glassly/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/glassly/crust/jsc/JSCPolyfillBridge.kt +287 -0
  11. package/android/src/main/java/com/glassly/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/glassly/crust/navigation/NavigationManager.kt +1466 -0
  13. package/android/src/main/java/com/glassly/crust/services/NotificationListener.kt +502 -0
  14. package/android/src/main/java/com/glassly/crust/services/NotificationProcessBridge.kt +222 -0
  15. package/android/src/main/java/com/glassly/crust/utils/ImageProcessor.java +452 -0
  16. package/android/src/main/java/com/glassly/crust/utils/VideoStabilizer.kt +556 -0
  17. package/android/src/main/res/values/strings.xml +3 -0
  18. package/app.plugin.js +3 -0
  19. package/build/Crust.types.d.ts +153 -0
  20. package/build/Crust.types.d.ts.map +1 -0
  21. package/build/Crust.types.js +2 -0
  22. package/build/Crust.types.js.map +1 -0
  23. package/build/CrustModule.d.ts +186 -0
  24. package/build/CrustModule.d.ts.map +1 -0
  25. package/build/CrustModule.js +4 -0
  26. package/build/CrustModule.js.map +1 -0
  27. package/build/CrustModule.web.d.ts +39 -0
  28. package/build/CrustModule.web.d.ts.map +1 -0
  29. package/build/CrustModule.web.js +69 -0
  30. package/build/CrustModule.web.js.map +1 -0
  31. package/build/CrustView.d.ts +4 -0
  32. package/build/CrustView.d.ts.map +1 -0
  33. package/build/CrustView.js +8 -0
  34. package/build/CrustView.js.map +1 -0
  35. package/build/CrustView.web.d.ts +3 -0
  36. package/build/CrustView.web.d.ts.map +1 -0
  37. package/build/CrustView.web.js +5 -0
  38. package/build/CrustView.web.js.map +1 -0
  39. package/build/index.d.ts +4 -0
  40. package/build/index.d.ts.map +1 -0
  41. package/build/index.js +6 -0
  42. package/build/index.js.map +1 -0
  43. package/expo-module.config.json +9 -0
  44. package/ios/Crust.podspec +65 -0
  45. package/ios/CrustModule.swift +784 -0
  46. package/ios/CrustView.swift +38 -0
  47. package/ios/Resources/startup.js +804 -0
  48. package/ios/Source/JSCDispatcher.swift +226 -0
  49. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  50. package/ios/Source/JSCRuntime.swift +674 -0
  51. package/ios/Source/utils/ImageProcessor.swift +392 -0
  52. package/ios/Source/utils/SystemGestures.swift +53 -0
  53. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  54. package/ios/heading/HeadingManager.swift +74 -0
  55. package/ios/navigation/NavPayloads.swift +62 -0
  56. package/ios/navigation/NavigationManager.swift +751 -0
  57. package/package.json +69 -0
  58. package/plugin/build/index.d.ts +21 -0
  59. package/plugin/build/index.js +25 -0
  60. package/plugin/build/withAndroid.d.ts +2 -0
  61. package/plugin/build/withAndroid.js +133 -0
  62. package/src/Crust.types.ts +162 -0
  63. package/src/CrustModule.ts +194 -0
  64. package/src/CrustModule.web.ts +72 -0
  65. package/src/CrustView.tsx +10 -0
  66. package/src/CrustView.web.tsx +9 -0
  67. package/src/index.ts +5 -0
@@ -0,0 +1,674 @@
1
+ //
2
+ // JSCRuntime.swift
3
+ // GlasslyJS — per-miniapp JavaScriptCore runtime host (iOS).
4
+ //
5
+ // Owns N JSContexts keyed by `packageName`. Each context gets:
6
+ // - its own JSVirtualMachine (heap isolation)
7
+ // - its own serial dispatch queue (JSC is thread-affine)
8
+ // - the polyfill bundle pre-evaluated before any miniapp code runs
9
+ // - a single `__dispatch(iface, method, argsJson)` block exposing the
10
+ // whole SDK surface (Pebble's CrashReproducer warning: never bind
11
+ // individual native callbacks as JSValue properties — JSC's GC
12
+ // crashes when the host runtime's GC races with it).
13
+ //
14
+ // The Expo Function surface on `CrustModule`:
15
+ // jsSpawn(packageName, polyfillBundle, miniappJs) → Bool
16
+ // jsEvaluate(packageName, src) → Any?
17
+ // jsKill(packageName) → Void
18
+ // jsDispatchToJs(packageName, channel, payloadJson) → Void
19
+ // Event "glasslyjs_message" — fired when JS calls __dispatch back.
20
+ //
21
+
22
+ import Foundation
23
+ @preconcurrency import JavaScriptCore
24
+ import os.log
25
+
26
+ /// Per-miniapp JSContext owner. Threadsafe: every per-context operation
27
+ /// hops onto that context's dedicated serial queue, and the registry map
28
+ /// is guarded by `lock`. The runtime is a process-singleton — there is
29
+ /// exactly one `JSCRuntime.shared` per host process.
30
+ public final class JSCRuntime: NSObject {
31
+ public static let shared = JSCRuntime()
32
+
33
+ /// Tag for os_log lines; visible via Console.app under
34
+ /// `subsystem == "com.tetramo.glassly" && category == "GlasslyJS"`.
35
+ static let log = OSLog(subsystem: "com.tetramo.glassly", category: "GlasslyJS")
36
+
37
+ /// Message emitted back to RN via `Crust.addListener("glasslyjs_message", …)`.
38
+ /// `payload` is JSON-friendly so the bridge can ship it verbatim.
39
+ public struct OutboundMessage {
40
+ public let packageName: String
41
+ public let payload: [String: Any]
42
+ }
43
+
44
+ /// Set by `CrustModule.swift` so the runtime can route `__dispatch`
45
+ /// calls back to RN. Replacing this closure is idempotent and threadsafe.
46
+ public var onOutbound: ((OutboundMessage) -> Void)? {
47
+ get { lock.withLock { _onOutbound } }
48
+ set { lock.withLock { _onOutbound = newValue } }
49
+ }
50
+
51
+ // MARK: - Per-context state
52
+
53
+ /// Internal record per running miniapp.
54
+ private final class Context {
55
+ let packageName: String
56
+ let virtualMachine: JSVirtualMachine
57
+ let context: JSContext
58
+ let queue: DispatchQueue
59
+ /// Pending request resolvers (reqId → completion). Used when the SDK
60
+ /// awaits a Promise for a request/response dispatch. Modify only on
61
+ /// `queue`.
62
+ var pendingTimers: [Int: DispatchSourceTimer] = [:]
63
+ /// Monotonic per-context counter for native-issued reqIds.
64
+ var nextTimerToken: Int = 1
65
+ /// signalReady NACK timer. Armed at spawn (15s cold-start) and
66
+ /// on every dispatchToJs (3s steady-state). Disarmed when the
67
+ /// polyfill calls __runtime.ready or when the call completes.
68
+ var readyNackTimer: DispatchSourceTimer?
69
+ /// True once the polyfill has signalled ready. Cleared on respawn.
70
+ var readyAcked: Bool = false
71
+ /// Soft watchdog — fires if a single evaluateScript blocks the
72
+ /// queue for >5s warn / >30s kill.
73
+ var watchdogTimer: DispatchSourceTimer?
74
+
75
+ init(packageName: String, virtualMachine: JSVirtualMachine, context: JSContext, queue: DispatchQueue) {
76
+ self.packageName = packageName
77
+ self.virtualMachine = virtualMachine
78
+ self.context = context
79
+ self.queue = queue
80
+ }
81
+ }
82
+
83
+ /// NACK timeout constants — cold-start vs steady-state.
84
+ /// 15s on first message after spawn (covers polyfill + init
85
+ /// evaluating on slow Android devices), 3s for steady-state delivery.
86
+ public static let coldStartNackTimeoutSeconds: TimeInterval = 15
87
+ public static let steadyStateNackTimeoutSeconds: TimeInterval = 3
88
+ public static let watchdogWarnSeconds: TimeInterval = 5
89
+ public static let watchdogKillSeconds: TimeInterval = 30
90
+
91
+ /// packageName → Context. Reads and writes go through `lock`.
92
+ private var contexts: [String: Context] = [:]
93
+ private let lock = NSLock()
94
+ private var _onOutbound: ((OutboundMessage) -> Void)?
95
+
96
+ /// Dispatcher registry — `(iface, method)` → native handler. Set up by
97
+ /// `JSCDispatcher.register(...)` during host startup. The runtime
98
+ /// consults this for every `__dispatch` call.
99
+ fileprivate let dispatcher = JSCDispatcher()
100
+
101
+ /// Public accessor so RN-side adapters can install handler tables.
102
+ public var dispatcherTable: JSCDispatcher { dispatcher }
103
+
104
+ /// Returns true if the named package has a live JSContext.
105
+ public func isAlive(packageName: String) -> Bool {
106
+ lock.withLock { contexts[packageName] != nil }
107
+ }
108
+
109
+ /// Locate the polyfill bundle inside the iOS pod's resource bundle
110
+ /// and return its contents. Returns an empty string if the resource
111
+ /// is missing (host RN code should treat that as a fatal misconfig
112
+ /// — every JSContext spawn depends on this bundle).
113
+ public static func loadPolyfillBundle() -> String {
114
+ // Cocoapods generates `JSRuntime.bundle` next to the pod
115
+ // binary. The runtime resolves it via Bundle(for:).
116
+ let main = Bundle.main
117
+ // The resource_bundles directive on the podspec (Crust.podspec)
118
+ // emits a bundle named "JSRuntime" inside the main app bundle's
119
+ // path — the name here MUST match the podspec key.
120
+ // We look it up by name; fall back to scanning Bundle.main for
121
+ // a startup.js anywhere if cocoapods placed it differently
122
+ // (e.g. inside the Crust framework bundle in static linkage).
123
+ let candidates: [URL?] = [
124
+ main.url(forResource: "JSRuntime", withExtension: "bundle"),
125
+ Bundle(for: JSCRuntime.self).url(forResource: "JSRuntime", withExtension: "bundle"),
126
+ ]
127
+ for case let bundleUrl? in candidates {
128
+ let startupUrl = bundleUrl.appendingPathComponent("startup.js")
129
+ if let data = try? Data(contentsOf: startupUrl), let str = String(data: data, encoding: .utf8) {
130
+ return str
131
+ }
132
+ }
133
+ // Last-resort scan — find any startup.js in the main bundle.
134
+ if let path = main.path(forResource: "startup", ofType: "js"),
135
+ let str = try? String(contentsOfFile: path, encoding: .utf8) {
136
+ return str
137
+ }
138
+ os_log("GlasslyJS: polyfill bundle not found in resources", log: Self.log, type: .error)
139
+ return ""
140
+ }
141
+
142
+ /// Returns the packageNames of every live context — used for diagnostics
143
+ /// and the soft watchdog ping loop.
144
+ public func alivePackages() -> [String] {
145
+ lock.withLock { Array(contexts.keys) }
146
+ }
147
+
148
+ // MARK: - Spawn
149
+
150
+ /// Spawn a per-miniapp JS context.
151
+ ///
152
+ /// - Parameters:
153
+ /// - packageName: stable id (e.g. "com.alex.notes"). Must be unique
154
+ /// within the host process. Re-spawning a live id kills the old
155
+ /// context first.
156
+ /// - polyfillBundle: the contents of `glasslyjs-runtime/dist/startup.js`.
157
+ /// Evaluated first, before `miniappJs`.
158
+ /// - miniappJs: the miniapp's `background/index.js` source. Evaluated
159
+ /// immediately after the polyfill installs.
160
+ /// - Returns: true on success, false if either eval threw.
161
+ @discardableResult
162
+ public func spawn(packageName: String, polyfillBundle: String, miniappJs: String) -> Bool {
163
+ // Kill any prior context for the same package — re-spawn is allowed
164
+ // (hot-reload, sideload of new version, crash recovery).
165
+ if isAlive(packageName: packageName) {
166
+ kill(packageName: packageName)
167
+ }
168
+
169
+ let vm = JSVirtualMachine()!
170
+ let queue = DispatchQueue(label: "com.tetramo.glasslyjs.\(packageName)", qos: .userInitiated)
171
+ let ctx = JSContext(virtualMachine: vm)!
172
+ ctx.name = "GlasslyJS: \(packageName)"
173
+ #if DEBUG
174
+ if #available(iOS 16.4, *) {
175
+ ctx.isInspectable = true
176
+ }
177
+ #endif
178
+
179
+ let record = Context(packageName: packageName, virtualMachine: vm, context: ctx, queue: queue)
180
+ lock.withLock { contexts[packageName] = record }
181
+
182
+ // Cold-start NACK timer: armed BEFORE the first eval so it
183
+ // catches a wedged polyfill. The polyfill's __dispatch("__runtime",
184
+ // "ready", []) flips the record's readyAcked flag (see
185
+ // markReady). If we never see the ack, the timer logs a hung
186
+ // context warning so observability picks it up.
187
+ armReadyNackTimer(record: record, timeoutSeconds: Self.coldStartNackTimeoutSeconds, cold: true)
188
+
189
+ // All evaluation happens on `queue`. We `sync` for the bootstrap so
190
+ // the caller knows whether spawn succeeded before returning.
191
+ var success = true
192
+ queue.sync {
193
+ self.installNativeBridges(ctx: ctx, record: record)
194
+ success = self.evaluateCatching(record: record, label: "polyfill", source: polyfillBundle)
195
+ if success {
196
+ success = self.evaluateCatching(record: record, label: "miniapp", source: miniappJs)
197
+ }
198
+ }
199
+
200
+ if !success {
201
+ kill(packageName: packageName)
202
+ } else {
203
+ os_log("GlasslyJS: spawned %{public}@", log: Self.log, type: .info, packageName)
204
+ }
205
+ return success
206
+ }
207
+
208
+ /// Called from the dispatcher's __runtime.ready route when the polyfill
209
+ /// finishes installing. Clears the cold-start NACK timer.
210
+ public func markReady(packageName: String) {
211
+ guard let record = lock.withLock({ contexts[packageName] }) else { return }
212
+ record.queue.async {
213
+ record.readyAcked = true
214
+ record.readyNackTimer?.cancel()
215
+ record.readyNackTimer = nil
216
+ }
217
+ }
218
+
219
+ /// Schedule (or re-schedule) a NACK timer for the next message
220
+ /// delivery. Resets every time the host pushes to the JSContext.
221
+ private func armReadyNackTimer(record: Context, timeoutSeconds: TimeInterval, cold: Bool) {
222
+ record.queue.async {
223
+ record.readyNackTimer?.cancel()
224
+ let timer = DispatchSource.makeTimerSource(queue: record.queue)
225
+ timer.schedule(deadline: .now() + timeoutSeconds)
226
+ timer.setEventHandler { [weak self, weak record] in
227
+ guard let self, let record else { return }
228
+ let phase = cold ? "cold-start" : "steady-state"
229
+ os_log("GlasslyJS NACK: %{public}@ %{public}@ ready signal not received in %.0fs",
230
+ log: Self.log, type: .error,
231
+ record.packageName, phase, timeoutSeconds)
232
+ // Surface to RN as a recoverable error frame so the
233
+ // crash controller (if wired) can decide whether to
234
+ // respawn. We don't auto-kill — the spec says "host
235
+ // dispatchToJs returns an error to its caller"; we
236
+ // emit a structured event instead.
237
+ self.lock.withLock { self._onOutbound }?(
238
+ OutboundMessage(
239
+ packageName: record.packageName,
240
+ payload: [
241
+ "packageName": record.packageName,
242
+ "iface": "__error",
243
+ "method": "ready_nack",
244
+ "argsJson": JSCRuntime.jsonString(
245
+ from: ["phase": phase, "timeoutSeconds": timeoutSeconds],
246
+ ) ?? "{}",
247
+ ],
248
+ )
249
+ )
250
+ record.readyNackTimer = nil
251
+ }
252
+ record.readyNackTimer = timer
253
+ timer.resume()
254
+ }
255
+ }
256
+
257
+ /// Diagnostic: force a JSC garbage-collection cycle on the named
258
+ /// context. Used by the memory-leak hunt path and by tests.
259
+ /// Returns false if the context is dead.
260
+ @discardableResult
261
+ public func debugForceGC(packageName: String) -> Bool {
262
+ guard let record = lock.withLock({ contexts[packageName] }) else { return false }
263
+ record.queue.async {
264
+ JSGarbageCollect(record.context.jsGlobalContextRef)
265
+ }
266
+ return true
267
+ }
268
+
269
+ // MARK: - Evaluate
270
+
271
+ /// Run arbitrary JS inside the named context. Returns the JS return
272
+ /// value bridged to a Swift type (string / number / bool / array /
273
+ /// dictionary / NSNull), or `nil` if the context is dead or eval threw.
274
+ public func evaluate(packageName: String, source: String) -> Any? {
275
+ guard let record = lock.withLock({ contexts[packageName] }) else { return nil }
276
+ var result: Any?
277
+ record.queue.sync {
278
+ // evaluateCatching runs the script AND returns whether it
279
+ // threw. We want both the result + the exception capture, so
280
+ // inline the same dance here instead of re-evaluating
281
+ // (which would run any side effects twice).
282
+ record.context.exception = nil
283
+ let raw = record.context.evaluateScript(source)
284
+ if let exception = record.context.exception {
285
+ os_log("GlasslyJS [%{public}@] evaluate threw: %{public}@",
286
+ log: Self.log, type: .error,
287
+ record.packageName, exception.toString() ?? "unknown")
288
+ record.context.exception = nil
289
+ result = nil
290
+ return
291
+ }
292
+ result = raw?.toObject()
293
+ }
294
+ return result
295
+ }
296
+
297
+ // MARK: - Dispatch to JS
298
+
299
+ /// Push a `{kind: "event"|"response", …}` envelope into the miniapp's
300
+ /// JSContext via globalThis.__deliver. Threadsafe — hops onto the
301
+ /// per-context queue. Drops silently if the context is dead.
302
+ public func dispatchToJs(packageName: String, envelope: [String: Any]) {
303
+ guard let record = lock.withLock({ contexts[packageName] }) else { return }
304
+ guard let json = Self.jsonString(from: envelope) else {
305
+ os_log("GlasslyJS: bad envelope, drop", log: Self.log, type: .error)
306
+ return
307
+ }
308
+ // Steady-state NACK: re-arm the timer so a wedged JSContext
309
+ // surfaces a __error/ready_nack frame after 3s instead of silently
310
+ // swallowing the delivery. Only fires if a cold-start ack already
311
+ // landed — otherwise the cold-start timer is still ticking.
312
+ if record.readyAcked {
313
+ armReadyNackTimer(record: record, timeoutSeconds: Self.steadyStateNackTimeoutSeconds, cold: false)
314
+ }
315
+ record.queue.async {
316
+ let escaped = Self.jsStringLiteral(json)
317
+ // Soft watchdog: every evaluateScript outside spawn gets a
318
+ // wall-clock timer. If the eval is still running at the warn
319
+ // threshold (5s), log it; at the kill threshold (30s), tear
320
+ // the context down and emit __error/watchdog_kill so the
321
+ // crash controller (if wired) can drive respawn.
322
+ self.armSoftWatchdog(record: record, label: "__deliver")
323
+ _ = self.evaluateCatching(
324
+ record: record,
325
+ label: "__deliver",
326
+ source: "globalThis.__deliver(\(escaped));",
327
+ )
328
+ self.disarmSoftWatchdog(record: record)
329
+ // On a successful delivery, clear the NACK — the host
330
+ // observed the eval complete, so the context is responsive.
331
+ record.readyNackTimer?.cancel()
332
+ record.readyNackTimer = nil
333
+ }
334
+ }
335
+
336
+ /// Arms a wall-clock timer that fires if a single evaluateScript
337
+ /// blocks the per-context queue for > watchdogWarnSeconds. The
338
+ /// timer runs on a dedicated dispatch queue (NOT the per-context
339
+ /// one) so it can observe the queue being wedged.
340
+ private static let watchdogScheduler = DispatchQueue(
341
+ label: "com.tetramo.glasslyjs.watchdog",
342
+ qos: .utility,
343
+ )
344
+
345
+ private func armSoftWatchdog(record: Context, label: String) {
346
+ let timer = DispatchSource.makeTimerSource(queue: Self.watchdogScheduler)
347
+ let warn = Self.watchdogWarnSeconds
348
+ let kill = Self.watchdogKillSeconds
349
+ timer.schedule(deadline: .now() + warn, repeating: .never)
350
+ timer.setEventHandler { [weak self, weak record] in
351
+ guard let self, let record else { return }
352
+ os_log("GlasslyJS watchdog: %{public}@ %{public}@ blocked >%.0fs",
353
+ log: Self.log, type: .info,
354
+ record.packageName, label, warn)
355
+ // Schedule the kill timer immediately.
356
+ let killTimer = DispatchSource.makeTimerSource(queue: Self.watchdogScheduler)
357
+ killTimer.schedule(deadline: .now() + (kill - warn))
358
+ killTimer.setEventHandler { [weak self, weak record] in
359
+ guard let self, let record else { return }
360
+ os_log("GlasslyJS watchdog: %{public}@ blocked >%.0fs, killing",
361
+ log: Self.log, type: .error,
362
+ record.packageName, kill)
363
+ self.lock.withLock { self._onOutbound }?(
364
+ OutboundMessage(
365
+ packageName: record.packageName,
366
+ payload: [
367
+ "packageName": record.packageName,
368
+ "iface": "__error",
369
+ "method": "watchdog_kill",
370
+ "argsJson": JSCRuntime.jsonString(
371
+ from: ["label": label, "thresholdSeconds": kill],
372
+ ) ?? "{}",
373
+ ],
374
+ )
375
+ )
376
+ self.kill(packageName: record.packageName)
377
+ }
378
+ record.watchdogTimer = killTimer
379
+ killTimer.resume()
380
+ }
381
+ // Reuse the same field for the warn timer; it gets replaced by
382
+ // the kill timer when the warn fires.
383
+ record.watchdogTimer = timer
384
+ timer.resume()
385
+ }
386
+
387
+ private func disarmSoftWatchdog(record: Context) {
388
+ record.watchdogTimer?.cancel()
389
+ record.watchdogTimer = nil
390
+ }
391
+
392
+ // MARK: - Kill
393
+
394
+ /// Tear down a JS context. Order matters per the Pebble lesson
395
+ /// (CrashReproducer.kt teardown race): cancel scheduled work first,
396
+ /// drop references second, GC last. JSC's GC fires asynchronously
397
+ /// after the JSContext is released — if we hadn't cancelled the
398
+ /// timers and NACK watchdog, they'd fire against a freed context
399
+ /// and crash with EXC_BAD_ACCESS.
400
+ public func kill(packageName: String) {
401
+ let record = lock.withLock { () -> Context? in
402
+ let r = contexts[packageName]
403
+ contexts[packageName] = nil
404
+ return r
405
+ }
406
+ guard let record else { return }
407
+ record.queue.sync {
408
+ // 1) Cancel every scheduled timer (setTimeout/setInterval
409
+ // + NACK watchdog + soft watchdog) so no callback can
410
+ // fire against a freed JSContext.
411
+ for (_, timer) in record.pendingTimers {
412
+ timer.cancel()
413
+ }
414
+ record.pendingTimers.removeAll()
415
+ record.readyNackTimer?.cancel()
416
+ record.readyNackTimer = nil
417
+ record.watchdogTimer?.cancel()
418
+ record.watchdogTimer = nil
419
+ // 2) Clear the exception handler so any in-flight throw on
420
+ // the same queue doesn't try to call back into a torn-down
421
+ // record. exceptionHandler captures `record` weakly so
422
+ // this isn't strictly required, but it makes the
423
+ // teardown order explicit.
424
+ record.context.exceptionHandler = nil
425
+ // 3) Force GC. The JSContext is freed by ARC when its
426
+ // last reference drops; this call ensures finalisers run
427
+ // on the per-context thread we own rather than on JSC's
428
+ // Heap Helper Thread.
429
+ JSGarbageCollect(record.context.jsGlobalContextRef)
430
+ }
431
+ os_log("GlasslyJS: killed %{public}@", log: Self.log, type: .info, packageName)
432
+ }
433
+
434
+ // MARK: - Internals
435
+
436
+ private func installNativeBridges(ctx: JSContext, record: Context) {
437
+ // Critical: a SINGLE __dispatch block, per Pebble's CrashReproducer
438
+ // warning. We MUST NOT bind individual native callbacks as JSValue
439
+ // properties — JSC's GC races with ARC and crashes the host.
440
+ let dispatchBlock: @convention(block) (String, String, String) -> JSValue? = { [weak self, weak record] iface, method, argsJson in
441
+ guard let self, let record else { return nil }
442
+ // Decode args envelope. Two shapes:
443
+ // 1) one-shot: an array `[a1, a2, ...]`
444
+ // 2) request: `{args: [...], reqId: "1"}`
445
+ var args: [Any] = []
446
+ var reqId: String? = nil
447
+ if argsJson.hasPrefix("{") {
448
+ if let data = argsJson.data(using: .utf8),
449
+ let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
450
+ if let a = dict["args"] as? [Any] { args = a }
451
+ if let r = dict["reqId"] as? String { reqId = r }
452
+ }
453
+ } else if let data = argsJson.data(using: .utf8),
454
+ let a = try? JSONSerialization.jsonObject(with: data) as? [Any] {
455
+ args = a
456
+ }
457
+ // Hand off to the dispatcher. The dispatcher returns either:
458
+ // - .sync result (sync handler) → returned to JS now
459
+ // - .async (request was dispatched, response will arrive via
460
+ // dispatchToJs at handler completion) → returns null to JS
461
+ // - .error → forwarded to JS as a thrown Error
462
+ let outcome = self.dispatcher.handle(
463
+ packageName: record.packageName,
464
+ iface: iface,
465
+ method: method,
466
+ args: args,
467
+ reqId: reqId
468
+ )
469
+ switch outcome {
470
+ case .sync(let value):
471
+ return JSCRuntime.jsValue(from: value, in: ctx)
472
+ case .async:
473
+ return JSValue(nullIn: ctx)
474
+ case .error(let code, let message):
475
+ let errPayload: [String: Any] = [
476
+ "code": code,
477
+ "message": message ?? "",
478
+ ]
479
+ let dictVal = JSValue(object: errPayload, in: ctx)
480
+ let exception = ctx.evaluateScript("(function(p){var e = new Error(p.message||p.code); e.code = p.code; e.details = p.details; return e;})")?.call(withArguments: [dictVal as Any])
481
+ ctx.exception = exception
482
+ return nil
483
+ case .forwardToRn(let payload):
484
+ // For methods that we route through the RN adapter (display,
485
+ // mic, etc.), we emit an outbound event and return null. The
486
+ // RN side will eventually respond via dispatchToJs.
487
+ var enveloped: [String: Any] = payload
488
+ enveloped["packageName"] = record.packageName
489
+ enveloped["iface"] = iface
490
+ enveloped["method"] = method
491
+ if let reqId { enveloped["reqId"] = reqId }
492
+ self.lock.withLock { self._onOutbound }?(
493
+ OutboundMessage(packageName: record.packageName, payload: enveloped)
494
+ )
495
+ return JSValue(nullIn: ctx)
496
+ }
497
+ }
498
+ ctx.setObject(dispatchBlock, forKeyedSubscript: "__dispatch" as NSString)
499
+
500
+ // Host log sink — forwarded into Sentry breadcrumbs by RN.
501
+ let hostLog: @convention(block) (String, String) -> Void = { [weak self, weak record] level, messageJson in
502
+ guard let self, let record else { return }
503
+ self.lock.withLock { self._onOutbound }?(
504
+ OutboundMessage(
505
+ packageName: record.packageName,
506
+ payload: [
507
+ "packageName": record.packageName,
508
+ "iface": "__log",
509
+ "method": level,
510
+ "argsJson": messageJson,
511
+ ],
512
+ )
513
+ )
514
+ }
515
+ ctx.setObject(hostLog, forKeyedSubscript: "__hostLog" as NSString)
516
+
517
+ let hostError: @convention(block) (String) -> Void = { [weak self, weak record] payloadJson in
518
+ guard let self, let record else { return }
519
+ self.lock.withLock { self._onOutbound }?(
520
+ OutboundMessage(
521
+ packageName: record.packageName,
522
+ payload: [
523
+ "packageName": record.packageName,
524
+ "iface": "__error",
525
+ "method": "uncaught",
526
+ "argsJson": payloadJson,
527
+ ],
528
+ )
529
+ )
530
+ }
531
+ ctx.setObject(hostError, forKeyedSubscript: "__hostError" as NSString)
532
+
533
+ let hostUnhandledRejection: @convention(block) (String) -> Void = { [weak self, weak record] payloadJson in
534
+ guard let self, let record else { return }
535
+ self.lock.withLock { self._onOutbound }?(
536
+ OutboundMessage(
537
+ packageName: record.packageName,
538
+ payload: [
539
+ "packageName": record.packageName,
540
+ "iface": "__error",
541
+ "method": "unhandledRejection",
542
+ "argsJson": payloadJson,
543
+ ],
544
+ )
545
+ )
546
+ }
547
+ ctx.setObject(hostUnhandledRejection, forKeyedSubscript: "__hostUnhandledRejection" as NSString)
548
+
549
+ // Timer plumbing — JS calls these to schedule wall-clock callbacks
550
+ // and native fires globalThis.__deliverTimer(token) when each elapses.
551
+ let nativeSetTimeout: @convention(block) (Int, Double) -> Void = { [weak self, weak record] token, delayMs in
552
+ guard let self, let record else { return }
553
+ let timer = DispatchSource.makeTimerSource(queue: record.queue)
554
+ let interval = max(0, delayMs) / 1000.0
555
+ timer.schedule(deadline: .now() + interval)
556
+ timer.setEventHandler { [weak self, weak record] in
557
+ guard let self, let record else { return }
558
+ record.pendingTimers.removeValue(forKey: token)
559
+ let src = "globalThis.__deliverTimer && globalThis.__deliverTimer(\(token));"
560
+ _ = self.evaluateCatching(record: record, label: "timer:\(token)", source: src)
561
+ }
562
+ record.pendingTimers[token] = timer
563
+ timer.resume()
564
+ }
565
+ ctx.setObject(nativeSetTimeout, forKeyedSubscript: "__nativeSetTimeout" as NSString)
566
+
567
+ let nativeClearTimer: @convention(block) (Int) -> Void = { [weak record] token in
568
+ guard let record else { return }
569
+ record.queue.async {
570
+ if let timer = record.pendingTimers.removeValue(forKey: token) {
571
+ timer.cancel()
572
+ }
573
+ }
574
+ }
575
+ ctx.setObject(nativeClearTimer, forKeyedSubscript: "__nativeClearTimer" as NSString)
576
+
577
+ // Pebble's evalCatching pattern: install a global onerror trampoline
578
+ // so syntax errors and synchronous throws are caught even when they
579
+ // wouldn't otherwise fire window.onerror.
580
+ ctx.exceptionHandler = { [weak self, weak record] _, exception in
581
+ guard let self, let record, let exception else { return }
582
+ let message = exception.toString() ?? "Unknown JS exception"
583
+ let stack = exception.objectForKeyedSubscript("stack")?.toString() ?? ""
584
+ self.lock.withLock { self._onOutbound }?(
585
+ OutboundMessage(
586
+ packageName: record.packageName,
587
+ payload: [
588
+ "packageName": record.packageName,
589
+ "iface": "__error",
590
+ "method": "exception",
591
+ "argsJson": Self.jsonString(from: ["message": message, "stack": stack]) ?? "{}",
592
+ ],
593
+ )
594
+ )
595
+ os_log("GlasslyJS exception in %{public}@: %{public}@",
596
+ log: Self.log, type: .error,
597
+ record.packageName, message)
598
+ }
599
+ }
600
+
601
+ /// Run `source` inside `record.context` and report any thrown exception
602
+ /// via the context's `exceptionHandler` (already wired). Returns true if
603
+ /// no exception fired.
604
+ @discardableResult
605
+ private func evaluateCatching(record: Context, label: String, source: String) -> Bool {
606
+ // The exception handler hook clears `context.exception` itself.
607
+ record.context.exception = nil
608
+ _ = record.context.evaluateScript(source)
609
+ if let exception = record.context.exception {
610
+ let msg = exception.toString() ?? "unknown"
611
+ os_log("GlasslyJS [%{public}@] %{public}@ eval threw: %{public}@",
612
+ log: Self.log, type: .error,
613
+ record.packageName, label, msg)
614
+ record.context.exception = nil
615
+ return false
616
+ }
617
+ return true
618
+ }
619
+
620
+ // MARK: - Helpers
621
+
622
+ fileprivate static func jsonString(from value: Any) -> String? {
623
+ guard JSONSerialization.isValidJSONObject(value),
624
+ let data = try? JSONSerialization.data(withJSONObject: value, options: []),
625
+ let str = String(data: data, encoding: .utf8) else {
626
+ return nil
627
+ }
628
+ return str
629
+ }
630
+
631
+ fileprivate static func jsStringLiteral(_ s: String) -> String {
632
+ // Re-encode the JSON string into a JS string literal so the eval
633
+ // path receives the same characters. JSONSerialization gives us the
634
+ // safest path: dump as a single-element array and slice out the
635
+ // quoted entry.
636
+ if let data = try? JSONSerialization.data(withJSONObject: [s], options: []),
637
+ let arr = String(data: data, encoding: .utf8) {
638
+ return String(arr.dropFirst().dropLast())
639
+ }
640
+ // Fallback: manual escape.
641
+ return "\"" + s
642
+ .replacingOccurrences(of: "\\", with: "\\\\")
643
+ .replacingOccurrences(of: "\"", with: "\\\"")
644
+ .replacingOccurrences(of: "\n", with: "\\n")
645
+ + "\""
646
+ }
647
+
648
+ fileprivate static func jsValue(from any: Any?, in ctx: JSContext) -> JSValue? {
649
+ guard let any else { return JSValue(nullIn: ctx) }
650
+ if let n = any as? NSNull { _ = n; return JSValue(nullIn: ctx) }
651
+ return JSValue(object: any, in: ctx)
652
+ }
653
+ }
654
+
655
+ /// Small Lock helper so we can use the trailing-closure `withLock` ergonomics
656
+ /// without importing the OSLock type. Plain NSLock; uncontended in practice
657
+ /// (the runtime only touches the map on spawn / kill / event delivery).
658
+ extension NSLock {
659
+ @inlinable
660
+ func withLock<T>(_ block: () -> T) -> T {
661
+ lock()
662
+ defer { unlock() }
663
+ return block()
664
+ }
665
+
666
+ /// Void-returning overload so callers using the lock for state mutation
667
+ /// don't get "result of call unused" warnings.
668
+ @inlinable
669
+ func withLockVoid(_ block: () -> Void) {
670
+ lock()
671
+ defer { unlock() }
672
+ block()
673
+ }
674
+ }