@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,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
|
+
}
|