@mentra/crust 0.1.0-beta.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 +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/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 +1042 -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 +287 -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 +182 -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 +34 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +63 -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 +781 -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 +191 -0
  63. package/src/CrustModule.web.ts +66 -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,593 @@
1
+ package com.mentra.crust.jsc
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.dokar.quickjs.QuickJs
6
+ import com.dokar.quickjs.binding.function
7
+ import java.io.IOException
8
+ import java.util.concurrent.ConcurrentHashMap
9
+ import java.util.concurrent.ExecutorService
10
+ import java.util.concurrent.Executors
11
+ import java.util.concurrent.ScheduledExecutorService
12
+ import java.util.concurrent.ScheduledFuture
13
+ import java.util.concurrent.TimeUnit
14
+ import kotlinx.coroutines.Dispatchers
15
+ import kotlinx.coroutines.runBlocking
16
+
17
+ /**
18
+ * Thrown from the `__dispatch` binding to propagate a structured MentraJS
19
+ * dispatch failure (PERMISSION_NOT_DECLARED / INVALID_ARGS / etc.) back to
20
+ * the calling JS frame. Dokar3 surfaces Kotlin throwables from bindings as
21
+ * JS-side `throw new Error(message)`, so the SDK's send-request Promise
22
+ * correctly rejects with a real Error.
23
+ */
24
+ class MentraJSDispatchError(val code: String, message: String) : RuntimeException("$code: $message")
25
+
26
+ /**
27
+ * MentraJS — per-miniapp QuickJS runtime host on Android.
28
+ *
29
+ * Owns N QuickJs instances (via dokar3/quickjs-kt) keyed by packageName.
30
+ * Each context gets:
31
+ * - its own QuickJs (heap isolation; QuickJS is single-threaded so we hop
32
+ * every operation onto the context's dedicated SingleThreadExecutor).
33
+ * - six host-callable globals installed at spawn time
34
+ * (`__dispatch`, `__hostLog`, `__hostError`, `__hostUnhandledRejection`,
35
+ * `__nativeSetTimeout`, `__nativeClearTimer`) via dokar3's
36
+ * `QuickJs.function(name) { args -> ... }` extension. This maps directly
37
+ * to `JS_SetPropertyStr(JS_NewCFunction(...))` on the QuickJS C API and
38
+ * mirrors iOS Apple JSC's `ctx.setObject(closure, forKeyedSubscript:)`
39
+ * pattern point-for-point.
40
+ * - the polyfill bundle pre-evaluated before any miniapp code runs.
41
+ *
42
+ * Symmetric with iOS [JSCRuntime.swift]. The class name keeps the "JSC"
43
+ * prefix on Android even though the engine is QuickJS — cross-platform
44
+ * parity for log filters, Sentry tags, and developer mental model.
45
+ *
46
+ * Dokar3 drains pending Promise jobs after every `evaluate(...)` and after
47
+ * every async-binding completion via QuickJS's `JS_ExecutePendingJob` loop,
48
+ * so we don't need to manually pump microtasks.
49
+ *
50
+ * Threading rule: every QuickJs operation must run on its context's
51
+ * dedicated executor thread. We `executor.submit { runBlocking { qjs.evaluate(...) } }`
52
+ * to satisfy both dokar3's coroutine entry point and the thread-affinity
53
+ * invariant. We pass `Dispatchers.Unconfined` to `QuickJs.create()` so
54
+ * async-job callbacks resume on whatever thread completes them (the
55
+ * executor thread, since we hop there via `executor.submit`); passing
56
+ * `executor.asCoroutineDispatcher()` instead would deadlock because
57
+ * `runBlocking(dispatcher)` parks the executor thread inside its own event
58
+ * loop and the dispatcher would try to schedule continuations back through
59
+ * the same executor's task queue, which is no longer being drained.
60
+ *
61
+ * Re-entrancy rule: bindings must NOT call `qjs.evaluate(...)` on the same
62
+ * instance — dokar3 holds an internal mutex across evaluate, and a binding
63
+ * that re-enters would deadlock. Our `__dispatch` returns a string and may
64
+ * throw, but never re-evaluates JS, so this rule is satisfied by
65
+ * construction. Future bindings must observe it.
66
+ */
67
+ class JSCRuntime private constructor(private val appContext: Context) {
68
+ companion object {
69
+ private const val TAG = "MentraJS"
70
+
71
+ // NACK cold-start timeout (15s — covers polyfill + init on slow devices).
72
+ const val COLD_START_NACK_TIMEOUT_MS: Long = 15_000
73
+ // Steady-state NACK — wedged-JSContext detection.
74
+ const val STEADY_STATE_NACK_TIMEOUT_MS: Long = 3_000
75
+ // Soft watchdog warn / kill thresholds (matches iOS).
76
+ const val WATCHDOG_WARN_MS: Long = 5_000
77
+ const val WATCHDOG_KILL_MS: Long = 30_000
78
+
79
+ @Volatile
80
+ private var instance: JSCRuntime? = null
81
+
82
+ fun shared(appContext: Context): JSCRuntime {
83
+ return instance ?: synchronized(this) {
84
+ instance ?: JSCRuntime(appContext.applicationContext).also { instance = it }
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Subscribe to outbound `mentrajs_message` events. Set by [CrustModule]
91
+ * during `OnCreate` (or lazily on `mentraJsSpawn` if reactContext was
92
+ * null at OnCreate time). The runtime fires this for every __dispatch
93
+ * call that has no matching local route in [JSCDispatcher]. If this is
94
+ * still null when a __dispatch arrives, the frame is silently dropped
95
+ * — we log a one-time warning at the first drop and count subsequent
96
+ * ones, since otherwise a miniapp will just look dead with no error
97
+ * trail.
98
+ */
99
+ @Volatile
100
+ private var _onOutbound: ((OutboundMessage) -> Unit)? = null
101
+ var onOutbound: ((OutboundMessage) -> Unit)?
102
+ get() = _onOutbound
103
+ set(value) {
104
+ val prev = _onOutbound
105
+ _onOutbound = value
106
+ if (value != null && prev == null) {
107
+ Log.i(TAG, "onOutbound sink installed (dropped frames before this: $droppedOutboundCount)")
108
+ } else if (value == null) {
109
+ Log.w(TAG, "onOutbound sink cleared")
110
+ }
111
+ }
112
+
113
+ @Volatile private var droppedOutboundCount: Int = 0
114
+ private fun deliverOrDrop(message: OutboundMessage) {
115
+ val sink = _onOutbound
116
+ if (sink != null) {
117
+ sink.invoke(message)
118
+ return
119
+ }
120
+ val n = ++droppedOutboundCount
121
+ if (n == 1 || n % 50 == 0) {
122
+ val iface = message.payload["iface"]
123
+ val method = message.payload["method"]
124
+ Log.w(TAG, "outbound dropped (no sink): ${message.packageName} $iface.$method [drop #$n]")
125
+ }
126
+ }
127
+
128
+ data class OutboundMessage(
129
+ val packageName: String,
130
+ val payload: Map<String, Any?>,
131
+ )
132
+
133
+ val dispatcher: JSCDispatcher = JSCDispatcher(appContext)
134
+
135
+ private val contexts = ConcurrentHashMap<String, ContextRecord>()
136
+ private val polyfillBundle: String by lazy { loadPolyfillBundle() }
137
+
138
+ // Schedule pool for timers; per-context the executor is the same as the
139
+ // QuickJs single-thread executor, so timer fires happen on the JS thread.
140
+ private val timerScheduler: ScheduledExecutorService =
141
+ Executors.newScheduledThreadPool(1) { r ->
142
+ Thread(r, "MentraJS-timer-scheduler").apply { isDaemon = true }
143
+ }
144
+
145
+ private inner class ContextRecord(
146
+ val packageName: String,
147
+ val qjs: QuickJs,
148
+ val executor: ExecutorService,
149
+ val pendingTimers: MutableMap<Int, ScheduledFuture<*>> = ConcurrentHashMap(),
150
+ @Volatile var readyAcked: Boolean = false,
151
+ @Volatile var readyNackTimer: ScheduledFuture<*>? = null,
152
+ @Volatile var watchdogTimer: ScheduledFuture<*>? = null,
153
+ )
154
+
155
+ /**
156
+ * Called from the dispatcher's __runtime.ready route when the polyfill
157
+ * finishes installing. Clears the cold-start NACK timer.
158
+ */
159
+ fun markReady(packageName: String) {
160
+ val record = contexts[packageName] ?: return
161
+ record.readyAcked = true
162
+ record.readyNackTimer?.cancel(false)
163
+ record.readyNackTimer = null
164
+ }
165
+
166
+ private fun armReadyNackTimer(record: ContextRecord, timeoutMs: Long, cold: Boolean) {
167
+ record.readyNackTimer?.cancel(false)
168
+ val future = timerScheduler.schedule({
169
+ val phase = if (cold) "cold-start" else "steady-state"
170
+ Log.e(
171
+ TAG,
172
+ "NACK: ${record.packageName} $phase ready signal not received in ${timeoutMs}ms",
173
+ )
174
+ deliverOrDrop(
175
+ OutboundMessage(
176
+ record.packageName,
177
+ mapOf(
178
+ "packageName" to record.packageName,
179
+ "iface" to "__error",
180
+ "method" to "ready_nack",
181
+ "argsJson" to org.json.JSONObject(
182
+ mapOf("phase" to phase, "timeoutMs" to timeoutMs) as Map<*, *>,
183
+ ).toString(),
184
+ ),
185
+ )
186
+ )
187
+ record.readyNackTimer = null
188
+ }, timeoutMs, TimeUnit.MILLISECONDS)
189
+ record.readyNackTimer = future
190
+ }
191
+
192
+ /**
193
+ * Diagnostic: ask QuickJS to GC. Returns false when the context is dead.
194
+ */
195
+ fun debugForceGC(packageName: String): Boolean {
196
+ val record = contexts[packageName] ?: return false
197
+ return try {
198
+ record.executor.submit {
199
+ try {
200
+ record.qjs.gc()
201
+ } catch (_: Throwable) { /* ignore */ }
202
+ }
203
+ true
204
+ } catch (_: Throwable) {
205
+ false
206
+ }
207
+ }
208
+
209
+ fun isAlive(packageName: String): Boolean = contexts.containsKey(packageName)
210
+
211
+ fun alivePackages(): List<String> = contexts.keys.toList()
212
+
213
+ /**
214
+ * Read the bundled MentraJS polyfill (`assets/startup.js`) shipped
215
+ * inside the host APK. The file is sourced from the sibling
216
+ * @mentra/jspolyfill module via `sourceSets.main.assets.srcDirs`
217
+ * in this module's build.gradle. Cached after first read.
218
+ */
219
+ fun loadPolyfillBundle(): String {
220
+ try {
221
+ appContext.assets.open("startup.js").use { stream ->
222
+ return stream.bufferedReader().readText()
223
+ }
224
+ } catch (e: IOException) {
225
+ Log.e(TAG, "polyfill bundle missing from assets", e)
226
+ return ""
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Spawn a per-miniapp QuickJs context. Re-spawn is allowed — a live
232
+ * context for the same package is killed first.
233
+ *
234
+ * Returns true on success. The polyfill + miniapp source are evaluated
235
+ * synchronously on the new context's executor; this method waits for
236
+ * both evals to complete before returning.
237
+ */
238
+ fun spawn(packageName: String, polyfillBundleOverride: String?, miniappJs: String): Boolean {
239
+ if (isAlive(packageName)) {
240
+ kill(packageName)
241
+ }
242
+ val executor = Executors.newSingleThreadExecutor { r ->
243
+ Thread(r, "MentraJS-$packageName").apply { isDaemon = true }
244
+ }
245
+
246
+ // Create the QuickJs context ON the executor thread. We pass
247
+ // `Dispatchers.Unconfined` as the jobDispatcher: dokar3 only uses it
248
+ // to schedule async-job callbacks via `coroutineScope.launch`. If we
249
+ // passed `executor.asCoroutineDispatcher()` instead, then
250
+ // `runBlocking(dispatcher) { evaluate(...) }` would deadlock —
251
+ // runBlocking parks the executor thread inside its own event loop,
252
+ // but the dispatcher tries to schedule the suspend body back through
253
+ // the same executor's task queue, which is no longer being drained.
254
+ // With Unconfined the suspend continuations resume on whatever thread
255
+ // completes them — which, since every operation is hopped onto the
256
+ // executor via `executor.submit { ... }`, is always the same thread T.
257
+ // The dokar3 internal jsMutex serializes any cross-thread access
258
+ // anyway, so this is safe.
259
+ val qjs = try {
260
+ executor.submit<QuickJs> {
261
+ QuickJs.create(Dispatchers.Unconfined).apply {
262
+ memoryLimit = 32L * 1024 * 1024 // 32 MB heap per miniapp
263
+ maxStackSize = 512L * 1024 // 512 KB stack
264
+ }
265
+ }.get()
266
+ } catch (e: Throwable) {
267
+ Log.e(TAG, "QuickJs.create() failed for $packageName", e)
268
+ executor.shutdownNow()
269
+ return false
270
+ }
271
+
272
+ val record = ContextRecord(
273
+ packageName = packageName,
274
+ qjs = qjs,
275
+ executor = executor,
276
+ )
277
+ contexts[packageName] = record
278
+
279
+ // Arm cold-start NACK before the first eval — catches a wedged
280
+ // polyfill or miniapp init. Cleared by markReady() when the
281
+ // polyfill bundle's __dispatch("__runtime", "ready") lands.
282
+ armReadyNackTimer(record, COLD_START_NACK_TIMEOUT_MS, cold = true)
283
+
284
+ val bundle = polyfillBundleOverride ?: polyfillBundle
285
+
286
+ return try {
287
+ executor.submit<Boolean> {
288
+ runBlocking {
289
+ installGlobals(qjs, packageName)
290
+ if (bundle.isNotEmpty()) {
291
+ qjs.evaluate<Any?>(bundle, filename = "mentrajs:startup.js")
292
+ }
293
+ if (miniappJs.isNotEmpty()) {
294
+ qjs.evaluate<Any?>(miniappJs, filename = "mentrajs:miniapp.js")
295
+ }
296
+ }
297
+ Log.i(TAG, "spawned $packageName")
298
+ true
299
+ }.get()
300
+ } catch (e: Throwable) {
301
+ Log.e(TAG, "spawn failed for $packageName: ${e.message}", e)
302
+ kill(packageName)
303
+ false
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Install the six host-callable JS globals on `qjs`'s `globalThis`,
309
+ * mirroring iOS `JSCRuntime.swift:497–574`. Must run on the context's
310
+ * executor thread; dokar3's `function` extension is sync.
311
+ */
312
+ private fun installGlobals(qjs: QuickJs, packageName: String) {
313
+ // __dispatch: sync. Returns JSON or null. Throws on dispatcher Error.
314
+ qjs.function("__dispatch") { args ->
315
+ // Bail if the context was killed mid-dispatch. The JS frame won't
316
+ // see this return because dokar3 will have already torn down the
317
+ // engine — but it's defensive against the brief window where the
318
+ // QuickJs is alive but the host has dropped the record.
319
+ if (contexts[packageName] == null) return@function null
320
+ val iface = args[0] as? String
321
+ ?: throw MentraJSDispatchError("INVALID_ARGS", "iface")
322
+ val method = args[1] as? String
323
+ ?: throw MentraJSDispatchError("INVALID_ARGS", "method")
324
+ val argsJson = args[2] as? String
325
+ ?: throw MentraJSDispatchError("INVALID_ARGS", "argsJson")
326
+ val parsed = parseArgsEnvelope(argsJson)
327
+ val outcome = dispatcher.handle(
328
+ packageName = packageName,
329
+ iface = iface,
330
+ method = method,
331
+ args = parsed.first,
332
+ reqId = parsed.second,
333
+ )
334
+ when (outcome) {
335
+ is JSCDispatchOutcome.Sync -> outcome.json
336
+ is JSCDispatchOutcome.Async -> null
337
+ is JSCDispatchOutcome.Error ->
338
+ throw MentraJSDispatchError(outcome.code, outcome.message ?: outcome.code)
339
+ is JSCDispatchOutcome.ForwardToRn -> {
340
+ val payload = HashMap<String, Any?>(outcome.payload)
341
+ payload["packageName"] = packageName
342
+ payload["iface"] = iface
343
+ payload["method"] = method
344
+ parsed.second?.let { payload["reqId"] = it }
345
+ deliverOrDrop(OutboundMessage(packageName, payload))
346
+ null
347
+ }
348
+ }
349
+ }
350
+
351
+ // __hostLog: fire-and-forget. Routes to __log outbound.
352
+ qjs.function<Unit>("__hostLog") { args ->
353
+ deliverOrDrop(
354
+ OutboundMessage(
355
+ packageName,
356
+ mapOf(
357
+ "packageName" to packageName,
358
+ "iface" to "__log",
359
+ "method" to (args[0] as? String ?: "log"),
360
+ "argsJson" to (args[1] as? String ?: "[]"),
361
+ ),
362
+ )
363
+ )
364
+ }
365
+
366
+ // __hostError: window.onerror trampoline.
367
+ qjs.function<Unit>("__hostError") { args ->
368
+ deliverOrDrop(
369
+ OutboundMessage(
370
+ packageName,
371
+ mapOf(
372
+ "packageName" to packageName,
373
+ "iface" to "__error",
374
+ "method" to "uncaught",
375
+ "argsJson" to (args[0] as? String ?: "{}"),
376
+ ),
377
+ )
378
+ )
379
+ }
380
+
381
+ // __hostUnhandledRejection: Promise unhandledrejection trampoline.
382
+ qjs.function<Unit>("__hostUnhandledRejection") { args ->
383
+ deliverOrDrop(
384
+ OutboundMessage(
385
+ packageName,
386
+ mapOf(
387
+ "packageName" to packageName,
388
+ "iface" to "__error",
389
+ "method" to "unhandledRejection",
390
+ "argsJson" to (args[0] as? String ?: "{}"),
391
+ ),
392
+ )
393
+ )
394
+ }
395
+
396
+ // __nativeSetTimeout: schedule wallclock callback. Fires __deliverTimer.
397
+ qjs.function<Unit>("__nativeSetTimeout") { args ->
398
+ val token = (args[0] as? Number)?.toInt() ?: return@function
399
+ val delayMs = (args[1] as? Number)?.toLong()?.coerceAtLeast(0L) ?: 0L
400
+ scheduleTimer(packageName, token, delayMs)
401
+ }
402
+
403
+ // __nativeClearTimer: cancel scheduled.
404
+ qjs.function<Unit>("__nativeClearTimer") { args ->
405
+ val token = (args[0] as? Number)?.toInt() ?: return@function
406
+ contexts[packageName]?.pendingTimers?.remove(token)?.cancel(false)
407
+ }
408
+ }
409
+
410
+ private fun scheduleTimer(packageName: String, token: Int, delayMs: Long) {
411
+ val record = contexts[packageName] ?: return
412
+ val future = timerScheduler.schedule({
413
+ record.pendingTimers.remove(token)
414
+ try {
415
+ record.executor.submit {
416
+ runBlocking {
417
+ try {
418
+ record.qjs.evaluate<Any?>(
419
+ "globalThis.__deliverTimer && globalThis.__deliverTimer($token);",
420
+ filename = "mentrajs:timer-$token.js",
421
+ )
422
+ } catch (e: Throwable) {
423
+ Log.w(TAG, "timer fire threw in $packageName: ${e.message}")
424
+ }
425
+ }
426
+ }
427
+ } catch (_: java.util.concurrent.RejectedExecutionException) {
428
+ // Context already killed — drop silently.
429
+ }
430
+ }, delayMs, TimeUnit.MILLISECONDS)
431
+ record.pendingTimers[token] = future
432
+ }
433
+
434
+ /**
435
+ * Run arbitrary JS in the named context. Returns the JS return value
436
+ * coerced to a JSON-friendly type (string for objects, primitives
437
+ * passthrough). Returns null if the context is dead or eval threw.
438
+ */
439
+ fun evaluate(packageName: String, source: String): Any? {
440
+ val record = contexts[packageName] ?: return null
441
+ return try {
442
+ record.executor.submit<Any?> {
443
+ runBlocking {
444
+ record.qjs.evaluate<Any?>(
445
+ source,
446
+ filename = "mentrajs:eval-${System.nanoTime()}.js",
447
+ )
448
+ }
449
+ }.get()
450
+ } catch (e: Throwable) {
451
+ Log.w(TAG, "evaluate threw in $packageName: ${e.message}")
452
+ null
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Push a `{kind: "event"|"response", …}` envelope into the named
458
+ * context's globalThis.__deliver. Hops onto the per-context executor.
459
+ */
460
+ fun dispatchToJs(packageName: String, envelopeJson: String) {
461
+ val record = contexts[packageName] ?: return
462
+ // Steady-state NACK — re-arm so a wedged QuickJS context surfaces
463
+ // an __error/ready_nack frame after 3s instead of silently
464
+ // swallowing the delivery. Skipped during cold-start (timer still
465
+ // ticking from spawn).
466
+ if (record.readyAcked) {
467
+ armReadyNackTimer(record, STEADY_STATE_NACK_TIMEOUT_MS, cold = false)
468
+ }
469
+ try {
470
+ record.executor.submit {
471
+ // Soft watchdog around evaluate: warn at 5s, kill at 30s.
472
+ val warn = timerScheduler.schedule({
473
+ Log.i(TAG, "watchdog: $packageName __deliver blocked >${WATCHDOG_WARN_MS}ms")
474
+ }, WATCHDOG_WARN_MS, TimeUnit.MILLISECONDS)
475
+ val killTimer = timerScheduler.schedule({
476
+ Log.e(TAG, "watchdog: $packageName blocked >${WATCHDOG_KILL_MS}ms, killing")
477
+ deliverOrDrop(
478
+ OutboundMessage(
479
+ packageName,
480
+ mapOf(
481
+ "packageName" to packageName,
482
+ "iface" to "__error",
483
+ "method" to "watchdog_kill",
484
+ "argsJson" to org.json.JSONObject(
485
+ mapOf("thresholdMs" to WATCHDOG_KILL_MS) as Map<*, *>,
486
+ ).toString(),
487
+ ),
488
+ )
489
+ )
490
+ kill(packageName)
491
+ }, WATCHDOG_KILL_MS, TimeUnit.MILLISECONDS)
492
+ record.watchdogTimer = killTimer
493
+ try {
494
+ val source = "globalThis.__deliver(${jsStringLiteral(envelopeJson)});"
495
+ runBlocking {
496
+ record.qjs.evaluate<Any?>(source, filename = "mentrajs:deliver.js")
497
+ }
498
+ // Successful delivery — context is responsive.
499
+ record.readyNackTimer?.cancel(false)
500
+ record.readyNackTimer = null
501
+ } catch (e: Throwable) {
502
+ Log.w(TAG, "dispatchToJs threw in $packageName: ${e.message}", e)
503
+ } finally {
504
+ warn.cancel(false)
505
+ killTimer.cancel(false)
506
+ if (record.watchdogTimer === killTimer) record.watchdogTimer = null
507
+ }
508
+ }
509
+ } catch (_: java.util.concurrent.RejectedExecutionException) {
510
+ // Context killed mid-flight — drop. dispatchToJs is best-effort.
511
+ }
512
+ }
513
+
514
+ fun kill(packageName: String) {
515
+ val record = contexts.remove(packageName) ?: return
516
+ // Cancel timers first so no scheduled fire-callback grabs the
517
+ // QuickJs after it's closed. Order matches the iOS teardown:
518
+ // setTimeout/setInterval → NACK watchdog → soft watchdog →
519
+ // close(qjs) → executor.shutdown.
520
+ for ((_, future) in record.pendingTimers) {
521
+ future.cancel(false)
522
+ }
523
+ record.pendingTimers.clear()
524
+ record.readyNackTimer?.cancel(false)
525
+ record.readyNackTimer = null
526
+ record.watchdogTimer?.cancel(false)
527
+ record.watchdogTimer = null
528
+ try {
529
+ record.executor.submit {
530
+ try {
531
+ record.qjs.close()
532
+ } catch (e: Throwable) {
533
+ Log.w(TAG, "QuickJs.close() threw for $packageName: ${e.message}")
534
+ }
535
+ }.get(2, TimeUnit.SECONDS)
536
+ } catch (e: Throwable) {
537
+ Log.w(TAG, "killed $packageName but cleanup hit ${e.javaClass.simpleName}")
538
+ } finally {
539
+ record.executor.shutdownNow()
540
+ }
541
+ Log.i(TAG, "killed $packageName")
542
+ }
543
+
544
+ // ------------------------------------------------------------------
545
+
546
+ private fun parseArgsEnvelope(argsJson: String): Pair<List<Any?>, String?> {
547
+ return try {
548
+ if (argsJson.startsWith("{")) {
549
+ val obj = org.json.JSONObject(argsJson)
550
+ val args = obj.optJSONArray("args")?.let(::jsonArrayToList) ?: emptyList()
551
+ val reqId = if (obj.has("reqId")) obj.optString("reqId").takeIf { it.isNotEmpty() } else null
552
+ args to reqId
553
+ } else {
554
+ jsonArrayToList(org.json.JSONArray(argsJson)) to null
555
+ }
556
+ } catch (_: Throwable) {
557
+ emptyList<Any?>() to null
558
+ }
559
+ }
560
+
561
+ private fun jsonArrayToList(arr: org.json.JSONArray): List<Any?> {
562
+ val out = ArrayList<Any?>(arr.length())
563
+ for (i in 0 until arr.length()) {
564
+ out += jsonValueToAny(arr.opt(i))
565
+ }
566
+ return out
567
+ }
568
+
569
+ private fun jsonValueToAny(v: Any?): Any? {
570
+ return when (v) {
571
+ null, org.json.JSONObject.NULL -> null
572
+ is org.json.JSONArray -> jsonArrayToList(v)
573
+ is org.json.JSONObject -> {
574
+ val m = HashMap<String, Any?>(v.length())
575
+ val it = v.keys()
576
+ while (it.hasNext()) {
577
+ val k = it.next()
578
+ m[k] = jsonValueToAny(v.opt(k))
579
+ }
580
+ m
581
+ }
582
+ else -> v
583
+ }
584
+ }
585
+
586
+ private fun jsStringLiteral(s: String): String {
587
+ // Re-encode via JSONArray so quotes / backslashes / control chars
588
+ // are escaped correctly for embedding in a JS source string.
589
+ return org.json.JSONArray().put(s).toString().let { arr ->
590
+ arr.substring(1, arr.length - 1)
591
+ }
592
+ }
593
+ }