@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.
- package/README.md +35 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +882 -0
- package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +246 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
- package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +148 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +175 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +26 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +54 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +7 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +4 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +7 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +544 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +814 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +673 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +720 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +19 -0
- package/plugin/build/index.js +23 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +78 -0
- package/src/Crust.types.ts +157 -0
- package/src/CrustModule.ts +186 -0
- package/src/CrustModule.web.ts +57 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +11 -0
- 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
|
+
}
|