@mentra/crust 0.1.0-dev.0

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