@mentra/crust 3.3.0-dev.943 → 3.3.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.
@@ -149,6 +149,15 @@ dependencies {
149
149
  dependencies {
150
150
  testImplementation 'junit:junit:4.13.2'
151
151
  testImplementation 'org.robolectric:robolectric:4.11.1'
152
+ // Local HTTP server for the miniapp fetch bridge tests; matches okhttp above.
153
+ testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
154
+ }
155
+
156
+ // Robolectric loads classes, and so QuickJS's JNI library, in its own sandbox
157
+ // class loader. Plain JUnit QuickJS tests in the same JVM then crash inside
158
+ // JNI, so every test class runs in a fresh JVM.
159
+ tasks.withType(Test).configureEach {
160
+ forkEvery = 1
152
161
  }
153
162
 
154
163
  // A desktop JVM cannot load the Android QuickJS library, so local unit tests
@@ -2,6 +2,8 @@ package com.mentra.crust.jsc
2
2
 
3
3
  import android.util.Log
4
4
  import java.io.IOException
5
+ import java.nio.ByteBuffer
6
+ import java.nio.charset.CharacterCodingException
5
7
  import okhttp3.MediaType.Companion.toMediaTypeOrNull
6
8
  import okhttp3.OkHttpClient
7
9
  import okhttp3.Request
@@ -12,6 +14,7 @@ import okhttp3.Callback
12
14
  import okhttp3.WebSocket
13
15
  import okhttp3.WebSocketListener
14
16
  import okio.ByteString
17
+ import okio.ByteString.Companion.decodeBase64
15
18
  import okio.ByteString.Companion.toByteString
16
19
  import org.json.JSONObject
17
20
  import org.json.JSONArray
@@ -46,15 +49,23 @@ object JSCPolyfillBridge {
46
49
  }
47
50
 
48
51
  /** Keep host HTTP and miniapp fetch body semantics identical, including empty POSTs. */
49
- internal fun buildHttpRequest(method: String, url: String, headers: Map<String, String>, bodyString: String?): Request {
52
+ internal fun buildHttpRequest(
53
+ method: String,
54
+ url: String,
55
+ headers: Map<String, String>,
56
+ bodyString: String?,
57
+ bodyBytes: ByteString? = null,
58
+ ): Request {
50
59
  val builder = Request.Builder().url(url)
51
60
  for ((name, value) in headers) builder.header(name, value)
52
61
  val contentType = headers.entries
53
62
  .firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
54
63
  ?.value
64
+ val mediaType = (contentType ?: "application/octet-stream").toMediaTypeOrNull()
55
65
  val upperMethod = method.uppercase()
56
66
  val requestBody = when {
57
- bodyString != null -> bodyString.toRequestBody((contentType ?: "application/octet-stream").toMediaTypeOrNull())
67
+ bodyBytes != null -> bodyBytes.toRequestBody(mediaType)
68
+ bodyString != null -> bodyString.toRequestBody(mediaType)
58
69
  upperMethod == "POST" || upperMethod == "PUT" || upperMethod == "PATCH" -> "".toRequestBody(null)
59
70
  else -> null
60
71
  }
@@ -62,6 +73,49 @@ object JSCPolyfillBridge {
62
73
  return builder.build()
63
74
  }
64
75
 
76
+ /**
77
+ * The `result` of a `fetch.request` response envelope. The body is sent
78
+ * as the string `body` only when its bytes are exactly that string's
79
+ * UTF-8 encoding, and as `bodyBase64` otherwise, so binary bodies keep
80
+ * every byte. The JS polyfill decodes base64 bodies and applies UTF-8 rules.
81
+ */
82
+ internal fun fetchResult(response: Response): JSONObject {
83
+ val headerMap = mutableMapOf<String, String>()
84
+ for (name in response.headers.names()) {
85
+ headerMap[name.lowercase()] = response.headers.values(name).joinToString(", ")
86
+ }
87
+ val body = try {
88
+ response.body?.bytes() ?: ByteArray(0)
89
+ } catch (e: Throwable) {
90
+ Log.w(TAG, "fetch read body threw: ${e.message}")
91
+ ByteArray(0)
92
+ }
93
+ val text = exactUtf8(body)
94
+ return JSONObject().apply {
95
+ put("status", response.code)
96
+ put("statusText", response.message)
97
+ put("headers", JSONObject(headerMap as Map<*, *>))
98
+ if (text != null) {
99
+ put("body", text)
100
+ } else {
101
+ put("bodyBase64", body.toByteString().base64())
102
+ }
103
+ put("ok", response.code in 200..299)
104
+ }
105
+ }
106
+
107
+ /** [bytes] as a String when they are exactly its UTF-8 encoding, else null. */
108
+ private fun exactUtf8(bytes: ByteArray): String? {
109
+ // A new decoder reports malformed input, so binary fails at its first invalid byte.
110
+ val text = try {
111
+ Charsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString()
112
+ } catch (_: CharacterCodingException) {
113
+ return null
114
+ }
115
+ // Re-encoding proves the round trip even if a decoder lets an unpaired surrogate through.
116
+ return text.takeIf { it.toByteArray(Charsets.UTF_8).contentEquals(bytes) }
117
+ }
118
+
65
119
  /** Idempotent. Call once on host boot, after the dispatcher is created. */
66
120
  fun install(runtime: JSCRuntime) {
67
121
  installFetch(runtime)
@@ -201,8 +255,12 @@ object JSCPolyfillBridge {
201
255
  ks to vs
202
256
  }?.toMap() ?: emptyMap()
203
257
  val bodyString = req["body"] as? String
258
+ val bodyBytes = (req["bodyBase64"] as? String)?.let { encoded ->
259
+ encoded.decodeBase64()
260
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request: bodyBase64 is not base64")
261
+ }
204
262
 
205
- val request = buildHttpRequest(method, url, headers, bodyString)
263
+ val request = buildHttpRequest(method, url, headers, bodyString, bodyBytes)
206
264
 
207
265
  httpClient.newCall(request).enqueue(object : Callback {
208
266
  override fun onFailure(call: Call, e: IOException) {
@@ -210,33 +268,14 @@ object JSCPolyfillBridge {
210
268
  }
211
269
 
212
270
  override fun onResponse(call: Call, response: Response) {
213
- response.use { r ->
214
- val status = r.code
215
- val statusText = r.message
216
- val headerMap = mutableMapOf<String, String>()
217
- for (name in r.headers.names()) {
218
- headerMap[name.lowercase()] = r.headers.values(name).joinToString(", ")
219
- }
220
- val bodyStr = try {
221
- r.body?.string() ?: ""
222
- } catch (e: Throwable) {
223
- Log.w(TAG, "fetch read body threw: ${e.message}")
224
- ""
225
- }
226
- val envelope = JSONObject().apply {
227
- put("kind", "response")
228
- put("reqId", reqId)
229
- put("ok", true)
230
- put("result", JSONObject().apply {
231
- put("status", status)
232
- put("statusText", statusText)
233
- put("headers", JSONObject(headerMap as Map<*, *>))
234
- put("body", bodyStr)
235
- put("ok", status in 200..299)
236
- })
237
- }
238
- runtime.dispatchToJs(packageName, envelope.toString())
271
+ val result = response.use { fetchResult(it) }
272
+ val envelope = JSONObject().apply {
273
+ put("kind", "response")
274
+ put("reqId", reqId)
275
+ put("ok", true)
276
+ put("result", result)
239
277
  }
278
+ runtime.dispatchToJs(packageName, envelope.toString())
240
279
  }
241
280
  })
242
281
  JSCDispatchOutcome.Async
@@ -0,0 +1,219 @@
1
+ package com.mentra.crust.jsc
2
+
3
+ import java.io.File
4
+ import java.util.concurrent.LinkedBlockingQueue
5
+ import java.util.concurrent.TimeUnit
6
+ import kotlin.math.PI
7
+ import kotlin.math.roundToInt
8
+ import kotlin.math.sin
9
+ import okhttp3.MediaType.Companion.toMediaType
10
+ import okhttp3.Protocol
11
+ import okhttp3.Request
12
+ import okhttp3.Response
13
+ import okhttp3.ResponseBody.Companion.toResponseBody
14
+ import okhttp3.mockwebserver.Dispatcher
15
+ import okhttp3.mockwebserver.MockResponse
16
+ import okhttp3.mockwebserver.MockWebServer
17
+ import okhttp3.mockwebserver.RecordedRequest
18
+ import okio.Buffer
19
+ import okio.ByteString.Companion.decodeBase64
20
+ import org.junit.After
21
+ import org.junit.Assert.*
22
+ import org.junit.Before
23
+ import org.junit.Test
24
+ import org.junit.runner.RunWith
25
+ import org.robolectric.RobolectricTestRunner
26
+ import org.robolectric.RuntimeEnvironment
27
+ import org.robolectric.annotation.Config
28
+
29
+ /**
30
+ * Miniapp `fetch` end to end on Android's real stack: the shipped startup.js
31
+ * polyfill in QuickJS, [JSCRuntime]'s dispatcher and `__deliver` path, this
32
+ * bridge, and OkHttp against a local server. Each miniapp script reports
33
+ * through a `test` dispatch, which the runtime forwards to `onOutbound`.
34
+ */
35
+ @RunWith(RobolectricTestRunner::class)
36
+ @Config(sdk = [28], manifest = Config.NONE)
37
+ class FetchBridgeTest {
38
+ private val packageName = "com.mentra.fetchtest"
39
+ private val results = LinkedBlockingQueue<Map<*, *>>()
40
+ private lateinit var server: MockWebServer
41
+ private lateinit var runtime: JSCRuntime
42
+
43
+ /** A 24 kHz 16-bit sine followed by every byte value: mostly not valid UTF-8. */
44
+ private val pcm = ByteArray(4800 * 2 + 256).also { out ->
45
+ for (i in 0 until 4800) {
46
+ val sample = (sin(2 * PI * 220 * i / 24000) * 20000).roundToInt()
47
+ out[2 * i] = sample.toByte()
48
+ out[2 * i + 1] = (sample shr 8).toByte()
49
+ }
50
+ for (value in 0 until 256) out[9600 + value] = value.toByte()
51
+ }
52
+
53
+ /** Valid UTF-8 that is still binary data: a BOM, NUL and DEL must all survive. */
54
+ private val utf8Bytes = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) +
55
+ "{\"a\":\"é😀\"}".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00, 0x7F)
56
+
57
+ /** Two MB of valid UTF-8 text: arrayBuffer() re-encodes it inside the 32 MB miniapp heap. */
58
+ private val largeUtf8Text: ByteArray =
59
+ "héllo 😀 中文 chunk of reply text ".repeat(52_000).toByteArray(Charsets.UTF_8)
60
+
61
+ // crust's build.gradle ships this file as the runtime's startup.js asset.
62
+ private val polyfill: String by lazy { File("../../jspolyfill/assets/startup.js").readText() }
63
+
64
+ @Before fun setUp() {
65
+ server = MockWebServer().apply {
66
+ dispatcher = object : Dispatcher() {
67
+ override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) {
68
+ "/reply.pcm" -> response("application/octet-stream").setBody(Buffer().write(pcm))
69
+ "/utf8.bin" -> response("application/octet-stream").setBody(Buffer().write(utf8Bytes))
70
+ "/large.txt" -> response("text/plain").setBody(Buffer().write(largeUtf8Text))
71
+ "/data.json" -> response("application/json").setBody("{\"message\":\"héllo 😀 中文\"}")
72
+ "/upload" -> response("text/plain").setBody("ok")
73
+ else -> MockResponse().setResponseCode(404)
74
+ }
75
+ }
76
+ start()
77
+ }
78
+ runtime = JSCRuntime.shared(RuntimeEnvironment.getApplication())
79
+ JSCPolyfillBridge.install(runtime)
80
+ runtime.onOutbound = { message ->
81
+ if (message.payload["iface"] == "test") {
82
+ results.add((message.payload["args"] as List<*>).first() as Map<*, *>)
83
+ }
84
+ }
85
+ }
86
+
87
+ @After fun tearDown() {
88
+ runtime.kill(packageName)
89
+ runtime.onOutbound = null
90
+ server.shutdown()
91
+ }
92
+
93
+ @Test fun `arrayBuffer returns the exact bytes of a binary response`() {
94
+ val result = runMiniapp(
95
+ """
96
+ const response = await fetch(BASE + "/reply.pcm")
97
+ const bytes = new Uint8Array(await response.arrayBuffer())
98
+ return {status: response.status, type: response.headers.get("content-type"), bytes: Array.from(bytes)}
99
+ """,
100
+ )
101
+ assertEquals(200, result["status"])
102
+ assertEquals("application/octet-stream", result["type"])
103
+ assertArrayEquals(pcm, bytes(result["bytes"]))
104
+ }
105
+
106
+ @Test fun `arrayBuffer returns the exact bytes of a body that is valid UTF-8`() {
107
+ val result = runMiniapp(
108
+ """
109
+ const response = await fetch(BASE + "/utf8.bin")
110
+ return {bytes: Array.from(new Uint8Array(await response.arrayBuffer()))}
111
+ """,
112
+ )
113
+ assertArrayEquals(utf8Bytes, bytes(result["bytes"]))
114
+ }
115
+
116
+ @Test fun `arrayBuffer re-encodes a large UTF-8 body within the miniapp heap`() {
117
+ val result = runMiniapp(
118
+ """
119
+ const bytes = new Uint8Array(await (await fetch(BASE + "/large.txt")).arrayBuffer())
120
+ let sum = 0
121
+ for (let i = 0; i < bytes.length; i++) sum = (sum * 31 + bytes[i]) >>> 0
122
+ return {length: bytes.length, sum}
123
+ """,
124
+ )
125
+ var sum = 0L
126
+ for (byte in largeUtf8Text) sum = (sum * 31 + (byte.toInt() and 0xFF)) and 0xFFFFFFFFL
127
+ assertEquals(largeUtf8Text.size, (result["length"] as Number).toInt())
128
+ assertEquals(sum, (result["sum"] as Number).toLong())
129
+ }
130
+
131
+ @Test fun `responses are sent as text only when that text re-encodes to the same bytes`() {
132
+ val text = JSCPolyfillBridge.fetchResult(responseWith(utf8Bytes))
133
+ assertEquals(String(utf8Bytes, Charsets.UTF_8), text.getString("body"))
134
+ assertFalse(text.has("bodyBase64"))
135
+
136
+ val empty = JSCPolyfillBridge.fetchResult(responseWith(ByteArray(0)))
137
+ assertEquals("", empty.getString("body"))
138
+
139
+ // Truncated, overlong, surrogate and out-of-range sequences, then raw PCM.
140
+ for (binary in listOf(byteArrayOf(0x41, 0xE2.toByte(), 0x82.toByte()), byteArrayOf(0xC0.toByte(), 0x80.toByte()),
141
+ byteArrayOf(0xED.toByte(), 0xA0.toByte(), 0x80.toByte()), byteArrayOf(0xF4.toByte(), 0x90.toByte(), 0x80.toByte(), 0x80.toByte()), pcm)) {
142
+ val result = JSCPolyfillBridge.fetchResult(responseWith(binary))
143
+ assertFalse(result.has("body"))
144
+ assertArrayEquals(binary, result.getString("bodyBase64").decodeBase64()!!.toByteArray())
145
+ }
146
+ }
147
+
148
+ @Test fun `text and json decode a UTF-8 response`() {
149
+ val result = runMiniapp(
150
+ """
151
+ const text = await (await fetch(BASE + "/data.json")).text()
152
+ const json = await (await fetch(BASE + "/data.json")).json()
153
+ return {text, message: json.message}
154
+ """,
155
+ )
156
+ assertEquals("{\"message\":\"héllo 😀 中文\"}", result["text"])
157
+ assertEquals("héllo 😀 中文", result["message"])
158
+ }
159
+
160
+ @Test fun `binary request bodies reach the server byte for byte`() {
161
+ val result = runMiniapp(
162
+ """
163
+ const backing = Uint8Array.from({length: 600}, (_, i) => (i * 37 + 11) & 255)
164
+ const response = await fetch(BASE + "/upload", {
165
+ method: "POST",
166
+ headers: {"Content-Type": "audio/L16; rate=24000"},
167
+ body: backing.subarray(44),
168
+ })
169
+ return {status: response.status, text: await response.text()}
170
+ """,
171
+ )
172
+ assertEquals(200, result["status"])
173
+ assertEquals("ok", result["text"])
174
+ val upload = server.takeRequest()
175
+ val expected = ByteArray(600) { ((it * 37 + 11) and 255).toByte() }.copyOfRange(44, 600)
176
+ assertArrayEquals(expected, upload.body.readByteArray())
177
+ assertEquals("audio/L16; rate=24000", upload.getHeader("Content-Type"))
178
+ }
179
+
180
+ @Test fun `string request bodies keep their UTF-8 text and content type`() {
181
+ runMiniapp(
182
+ """
183
+ await fetch(BASE + "/upload", {method: "POST", headers: {"Content-Type": "application/json"}, body: '{"t":"é😀"}'})
184
+ return {}
185
+ """,
186
+ )
187
+ val upload = server.takeRequest()
188
+ assertEquals("{\"t\":\"é😀\"}", upload.body.readUtf8())
189
+ assertEquals("application/json; charset=utf-8", upload.getHeader("Content-Type"))
190
+ }
191
+
192
+ private fun runMiniapp(body: String): Map<*, *> {
193
+ val script =
194
+ """
195
+ const BASE = "${server.url("/").toString().removeSuffix("/")}"
196
+ const report = (value) => __mentraSendOneShot("test", "result", [value])
197
+ ;(async () => {
198
+ $body
199
+ })().then(report, (error) => report({error: String(error && (error.message || error))}))
200
+ """.trimIndent()
201
+ assertTrue("spawn failed", runtime.spawn(packageName, polyfill, script))
202
+ val result = results.poll(15, TimeUnit.SECONDS) ?: throw AssertionError("miniapp did not report a result")
203
+ assertNull("miniapp script threw", result["error"])
204
+ return result
205
+ }
206
+
207
+ private fun bytes(value: Any?): ByteArray =
208
+ (value as List<*>).map { (it as Number).toInt().toByte() }.toByteArray()
209
+
210
+ private fun response(contentType: String) = MockResponse().setHeader("Content-Type", contentType)
211
+
212
+ private fun responseWith(body: ByteArray): Response = Response.Builder()
213
+ .request(Request.Builder().url("https://example.com/body").build())
214
+ .protocol(Protocol.HTTP_1_1)
215
+ .code(200)
216
+ .message("OK")
217
+ .body(body.toResponseBody("application/octet-stream".toMediaType()))
218
+ .build()
219
+ }
@@ -436,39 +436,183 @@
436
436
  }
437
437
  ;
438
438
  g.crypto = cryptoNs;
439
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
440
+ const BASE64_VALUES = new Uint8Array(128).fill(255);
441
+ for (let i = 0; i < BASE64_ALPHABET.length; i++) BASE64_VALUES[BASE64_ALPHABET.charCodeAt(i)] = i;
442
+ const CHARS_PER_CHUNK = 8192;
443
+ function bytesOf(data) {
444
+ return data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
445
+ }
446
+ function bytesToBase64(bytes) {
447
+ const parts = [];
448
+ const chars = [];
449
+ for (let i = 0; i < bytes.length; i += 3) {
450
+ const b1 = bytes[i];
451
+ const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;
452
+ const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;
453
+ chars.push(
454
+ BASE64_ALPHABET.charCodeAt(b1 >> 2),
455
+ BASE64_ALPHABET.charCodeAt((b1 & 3) << 4 | b2 >> 4),
456
+ i + 1 < bytes.length ? BASE64_ALPHABET.charCodeAt((b2 & 15) << 2 | b3 >> 6) : 61,
457
+ i + 2 < bytes.length ? BASE64_ALPHABET.charCodeAt(b3 & 63) : 61
458
+ );
459
+ if (chars.length >= CHARS_PER_CHUNK) {
460
+ parts.push(String.fromCharCode.apply(null, chars));
461
+ chars.length = 0;
462
+ }
463
+ }
464
+ parts.push(String.fromCharCode.apply(null, chars));
465
+ return parts.join("");
466
+ }
467
+ function base64ToBytes(base64) {
468
+ const length = base64.length;
469
+ if (length % 4 !== 0) throw new TypeError("Invalid base64 from the native bridge");
470
+ const padding = length === 0 || base64.charCodeAt(length - 1) !== 61 ? 0 : base64.charCodeAt(length - 2) === 61 ? 2 : 1;
471
+ const out = new Uint8Array(length / 4 * 3 - padding);
472
+ const unpadded = padding === 0 ? length : length - 4;
473
+ let codes = 0;
474
+ let values = 0;
475
+ let o = 0;
476
+ for (let i = 0; i < unpadded; i += 4) {
477
+ const c1 = base64.charCodeAt(i);
478
+ const c2 = base64.charCodeAt(i + 1);
479
+ const c3 = base64.charCodeAt(i + 2);
480
+ const c4 = base64.charCodeAt(i + 3);
481
+ const v1 = BASE64_VALUES[c1 & 127];
482
+ const v2 = BASE64_VALUES[c2 & 127];
483
+ const v3 = BASE64_VALUES[c3 & 127];
484
+ const v4 = BASE64_VALUES[c4 & 127];
485
+ codes |= c1 | c2 | c3 | c4;
486
+ values |= v1 | v2 | v3 | v4;
487
+ out[o++] = v1 << 2 | v2 >> 4;
488
+ out[o++] = (v2 & 15) << 4 | v3 >> 2;
489
+ out[o++] = (v3 & 3) << 6 | v4;
490
+ }
491
+ if (padding > 0) {
492
+ const c1 = base64.charCodeAt(unpadded);
493
+ const c2 = base64.charCodeAt(unpadded + 1);
494
+ const c3 = padding === 1 ? base64.charCodeAt(unpadded + 2) : 65;
495
+ const v1 = BASE64_VALUES[c1 & 127];
496
+ const v2 = BASE64_VALUES[c2 & 127];
497
+ const v3 = BASE64_VALUES[c3 & 127];
498
+ codes |= c1 | c2 | c3;
499
+ values |= v1 | v2 | v3;
500
+ out[o++] = v1 << 2 | v2 >> 4;
501
+ if (padding === 1) out[o++] = (v2 & 15) << 4 | v3 >> 2;
502
+ }
503
+ if (codes > 127 || values > 63) throw new TypeError("Invalid base64 from the native bridge");
504
+ return out;
505
+ }
506
+ function decodeUtf8(bytes, fatal, ignoreBOM) {
507
+ const length = bytes.length;
508
+ const parts = [];
509
+ const units = [];
510
+ let i = !ignoreBOM && length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
511
+ while (i < length) {
512
+ const asciiStart = i;
513
+ while (i < length && bytes[i] < 128) i++;
514
+ if (i > asciiStart) {
515
+ if (units.length > 0) {
516
+ parts.push(String.fromCharCode.apply(null, units));
517
+ units.length = 0;
518
+ }
519
+ for (let start = asciiStart; start < i; start += CHARS_PER_CHUNK) {
520
+ const chunk = bytes.subarray(start, Math.min(i, start + CHARS_PER_CHUNK));
521
+ parts.push(String.fromCharCode.apply(null, chunk));
522
+ }
523
+ if (i >= length) break;
524
+ }
525
+ const lead = bytes[i++];
526
+ let codePoint;
527
+ let needed;
528
+ let lower = 128;
529
+ let upper = 191;
530
+ if (lead >= 194 && lead <= 223) {
531
+ needed = 1;
532
+ codePoint = lead & 31;
533
+ } else if (lead >= 224 && lead <= 239) {
534
+ if (lead === 224) lower = 160;
535
+ if (lead === 237) upper = 159;
536
+ needed = 2;
537
+ codePoint = lead & 15;
538
+ } else if (lead >= 240 && lead <= 244) {
539
+ if (lead === 240) lower = 144;
540
+ if (lead === 244) upper = 143;
541
+ needed = 3;
542
+ codePoint = lead & 7;
543
+ } else {
544
+ needed = -1;
545
+ codePoint = 0;
546
+ }
547
+ while (needed > 0) {
548
+ const next = i < length ? bytes[i] : -1;
549
+ if (next < lower || next > upper) break;
550
+ codePoint = codePoint << 6 | next & 63;
551
+ lower = 128;
552
+ upper = 191;
553
+ needed--;
554
+ i++;
555
+ }
556
+ if (needed !== 0) {
557
+ if (fatal) throw new TypeError("The encoded data was not valid utf-8");
558
+ units.push(65533);
559
+ } else if (codePoint > 65535) {
560
+ codePoint -= 65536;
561
+ units.push(55296 + (codePoint >> 10), 56320 + (codePoint & 1023));
562
+ } else {
563
+ units.push(codePoint);
564
+ }
565
+ if (units.length >= CHARS_PER_CHUNK) {
566
+ parts.push(String.fromCharCode.apply(null, units));
567
+ units.length = 0;
568
+ }
569
+ }
570
+ if (units.length > 0) parts.push(String.fromCharCode.apply(null, units));
571
+ return parts.join("");
572
+ }
439
573
  if (typeof g.TextEncoder !== "function") {
574
+ const codePointAt = (str, i) => {
575
+ const code = str.charCodeAt(i);
576
+ if (code >= 55296 && code <= 56319 && i + 1 < str.length) {
577
+ const next = str.charCodeAt(i + 1);
578
+ if (next >= 56320 && next <= 57343) return (code - 55296 << 10) + (next - 56320) + 65536;
579
+ }
580
+ return code;
581
+ };
440
582
  class TextEncoderPolyfill {
441
583
  constructor() {
442
584
  this.encoding = "utf-8";
443
585
  }
444
586
  encode(input) {
445
587
  const str = input ?? "";
446
- const out = [];
588
+ let length = 0;
447
589
  for (let i = 0; i < str.length; i++) {
448
- let code = str.charCodeAt(i);
449
- if (code >= 55296 && code <= 56319 && i + 1 < str.length) {
450
- const next = str.charCodeAt(i + 1);
451
- if (next >= 56320 && next <= 57343) {
452
- code = (code - 55296 << 10) + (next - 56320) + 65536;
453
- i++;
454
- }
455
- }
590
+ const code = codePointAt(str, i);
591
+ if (code > 65535) i++;
592
+ length += code < 128 ? 1 : code < 2048 ? 2 : code < 65536 ? 3 : 4;
593
+ }
594
+ const out = new Uint8Array(length);
595
+ let at = 0;
596
+ for (let i = 0; i < str.length; i++) {
597
+ const code = codePointAt(str, i);
598
+ if (code > 65535) i++;
456
599
  if (code < 128) {
457
- out.push(code);
600
+ out[at++] = code;
458
601
  } else if (code < 2048) {
459
- out.push(192 | code >> 6, 128 | code & 63);
602
+ out[at++] = 192 | code >> 6;
603
+ out[at++] = 128 | code & 63;
460
604
  } else if (code < 65536) {
461
- out.push(224 | code >> 12, 128 | code >> 6 & 63, 128 | code & 63);
605
+ out[at++] = 224 | code >> 12;
606
+ out[at++] = 128 | code >> 6 & 63;
607
+ out[at++] = 128 | code & 63;
462
608
  } else {
463
- out.push(
464
- 240 | code >> 18,
465
- 128 | code >> 12 & 63,
466
- 128 | code >> 6 & 63,
467
- 128 | code & 63
468
- );
609
+ out[at++] = 240 | code >> 18;
610
+ out[at++] = 128 | code >> 12 & 63;
611
+ out[at++] = 128 | code >> 6 & 63;
612
+ out[at++] = 128 | code & 63;
469
613
  }
470
614
  }
471
- return new Uint8Array(out);
615
+ return out;
472
616
  }
473
617
  }
474
618
  ;
@@ -476,36 +620,14 @@
476
620
  }
477
621
  if (typeof g.TextDecoder !== "function") {
478
622
  class TextDecoderPolyfill {
479
- constructor(encoding = "utf-8") {
623
+ constructor(encoding = "utf-8", options) {
480
624
  this.encoding = encoding;
625
+ this.fatal = options?.fatal === true;
626
+ this.ignoreBOM = options?.ignoreBOM === true;
481
627
  }
482
628
  decode(buffer) {
483
629
  if (!buffer) return "";
484
- const bytes = buffer instanceof Uint8Array ? buffer : buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
485
- let out = "";
486
- let i = 0;
487
- while (i < bytes.length) {
488
- const b1 = bytes[i++];
489
- let code;
490
- if (b1 < 128) {
491
- code = b1;
492
- } else if (b1 < 192) {
493
- code = 65533;
494
- } else if (b1 < 224) {
495
- code = (b1 & 31) << 6 | bytes[i++] & 63;
496
- } else if (b1 < 240) {
497
- code = (b1 & 15) << 12 | (bytes[i++] & 63) << 6 | bytes[i++] & 63;
498
- } else {
499
- code = (b1 & 7) << 18 | (bytes[i++] & 63) << 12 | (bytes[i++] & 63) << 6 | bytes[i++] & 63;
500
- }
501
- if (code > 65535) {
502
- code -= 65536;
503
- out += String.fromCharCode(55296 + (code >> 10), 56320 + (code & 1023));
504
- } else {
505
- out += String.fromCharCode(code);
506
- }
507
- }
508
- return out;
630
+ return decodeUtf8(bytesOf(buffer), this.fatal, this.ignoreBOM);
509
631
  }
510
632
  }
511
633
  ;
@@ -552,9 +674,12 @@
552
674
  const url = typeof input === "string" ? input : input.toString();
553
675
  const method = (init?.method ?? "GET").toUpperCase();
554
676
  let bodyString = null;
677
+ let bodyBase64;
555
678
  const reqBody = init?.body;
556
679
  if (typeof reqBody === "string") {
557
680
  bodyString = reqBody;
681
+ } else if (reqBody instanceof ArrayBuffer || ArrayBuffer.isView(reqBody)) {
682
+ bodyBase64 = bytesToBase64(bytesOf(reqBody));
558
683
  } else if (reqBody && typeof reqBody.toString === "function") {
559
684
  bodyString = reqBody.toString();
560
685
  }
@@ -578,9 +703,10 @@
578
703
  }
579
704
  const sendRequest = g.__mentraSendRequest;
580
705
  const result = await sendRequest("fetch", "request", [
581
- { url, method, headers, body: bodyString }
706
+ bodyBase64 === void 0 ? { url, method, headers, body: bodyString } : { url, method, headers, bodyBase64 }
582
707
  ]);
583
- const bodyText = typeof result.body === "string" ? result.body : "";
708
+ const responseText = typeof result.body === "string" ? result.body : null;
709
+ const responseBase64 = typeof result.bodyBase64 === "string" ? result.bodyBase64 : "";
584
710
  const responseHeaders = new Map(Object.entries(result.headers ?? {}));
585
711
  class ResponseLike {
586
712
  constructor() {
@@ -597,11 +723,13 @@
597
723
  };
598
724
  }
599
725
  async text() {
600
- return bodyText;
726
+ if (responseText === null) return decodeUtf8(base64ToBytes(responseBase64), false, false);
727
+ return responseText.charCodeAt(0) === 65279 ? responseText.slice(1) : responseText;
601
728
  }
602
729
  async json() {
730
+ const text = await this.text();
603
731
  try {
604
- return JSON.parse(bodyText);
732
+ return JSON.parse(text);
605
733
  } catch (e) {
606
734
  const err = new Error(
607
735
  `Failed to parse JSON response from ${url}: ${e instanceof Error ? e.message : String(e)}`
@@ -611,52 +739,21 @@
611
739
  }
612
740
  }
613
741
  async arrayBuffer() {
742
+ if (responseText === null) return base64ToBytes(responseBase64).buffer;
614
743
  const enc = new g.TextEncoder();
615
- return enc.encode(bodyText).buffer;
744
+ return enc.encode(responseText).buffer;
616
745
  }
617
746
  }
618
747
  return new ResponseLike();
619
748
  };
620
749
  }
621
750
  if (typeof g.WebSocket !== "function") {
622
- let bytesToBase642 = function(bytes) {
623
- const b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
624
- let out = "";
625
- let i = 0;
626
- while (i < bytes.length) {
627
- const c1 = bytes[i++];
628
- const c2 = i < bytes.length ? bytes[i++] : NaN;
629
- const c3 = i < bytes.length ? bytes[i++] : NaN;
630
- const e1 = c1 >> 2;
631
- const e2 = (c1 & 3) << 4 | (Number.isNaN(c2) ? 0 : c2 >> 4);
632
- const e3 = Number.isNaN(c2) ? 64 : (c2 & 15) << 2 | (Number.isNaN(c3) ? 0 : c3 >> 6);
633
- const e4 = Number.isNaN(c3) ? 64 : c3 & 63;
634
- out += b64[e1] + b64[e2] + (e3 === 64 ? "=" : b64[e3]) + (e4 === 64 ? "=" : b64[e4]);
635
- }
636
- return out;
637
- }, base64ToBytes2 = function(s) {
638
- const b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
639
- const clean = s.replace(/=+$/, "");
640
- const out = [];
641
- let buf = 0;
642
- let bits = 0;
643
- for (let i = 0; i < clean.length; i++) {
644
- const idx = b64.indexOf(clean[i]);
645
- if (idx < 0) continue;
646
- buf = buf << 6 | idx;
647
- bits += 6;
648
- if (bits >= 8) {
649
- bits -= 8;
650
- out.push(buf >> bits & 255);
651
- }
652
- }
653
- return new Uint8Array(out);
654
- }, queueMicrotaskSafe2 = function(fn) {
751
+ let queueMicrotaskSafe2 = function(fn) {
655
752
  const q = g.queueMicrotask;
656
753
  if (typeof q === "function") q(fn);
657
754
  else g.Promise.resolve().then(fn);
658
755
  };
659
- var bytesToBase64 = bytesToBase642, base64ToBytes = base64ToBytes2, queueMicrotaskSafe = queueMicrotaskSafe2;
756
+ var queueMicrotaskSafe = queueMicrotaskSafe2;
660
757
  const sockets = /* @__PURE__ */ new Map();
661
758
  g.__mentraDeliverWebSocketEvent = (sid, type, payload) => {
662
759
  const sock = sockets.get(sid);
@@ -718,8 +815,7 @@
718
815
  payload = data;
719
816
  } else {
720
817
  kind = "binary";
721
- const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
722
- payload = bytesToBase642(bytes);
818
+ payload = bytesToBase64(bytesOf(data));
723
819
  }
724
820
  try {
725
821
  __dispatch("ws", "send", JSON.stringify([{ sid: this.sid, kind, payload }]));
@@ -752,7 +848,7 @@
752
848
  }
753
849
  const synth = { type, target: this, ...ev };
754
850
  if (type === "message" && ev.kind === "binary" && typeof ev.data === "string") {
755
- synth.data = base64ToBytes2(ev.data).buffer;
851
+ synth.data = base64ToBytes(ev.data).buffer;
756
852
  } else if (type === "message" && ev.kind === "text") {
757
853
  synth.data = ev.data;
758
854
  }
@@ -100,14 +100,25 @@ public enum JSCPolyfillBridge {
100
100
  }
101
101
 
102
102
  var urlRequest = URLRequest(url: url)
103
- urlRequest.httpMethod = (req["method"] as? String) ?? "GET"
103
+ let method = (req["method"] as? String) ?? "GET"
104
+ urlRequest.httpMethod = method
104
105
  if let headers = req["headers"] as? [String: Any] {
105
106
  for (k, v) in headers {
106
107
  urlRequest.setValue(String(describing: v), forHTTPHeaderField: k)
107
108
  }
108
109
  }
109
- if let body = req["body"] as? String, !body.isEmpty {
110
- urlRequest.httpBody = body.data(using: .utf8)
110
+ // ArrayBuffer and typed-array bodies arrive as base64 of their bytes.
111
+ // As on Android, an empty body is still sent, and POST, PUT and PATCH
112
+ // always carry one, so the server sees Content-Length: 0.
113
+ if let encoded = req["bodyBase64"] as? String {
114
+ guard let data = Data(base64Encoded: encoded) else {
115
+ return .error(code: "INVALID_ARGS", message: "fetch.request: bodyBase64 is not base64")
116
+ }
117
+ urlRequest.httpBody = data
118
+ } else if let body = req["body"] as? String {
119
+ urlRequest.httpBody = Data(body.utf8)
120
+ } else if ["POST", "PUT", "PATCH"].contains(method.uppercased()) {
121
+ urlRequest.httpBody = Data()
111
122
  }
112
123
 
113
124
  // Reasonable defaults — caller can override later by passing
@@ -141,21 +152,27 @@ public enum JSCPolyfillBridge {
141
152
  }
142
153
  return out
143
154
  }()
144
- let bodyStr: String = {
145
- guard let data else { return "" }
146
- return String(data: data, encoding: .utf8) ?? ""
147
- }()
155
+ var result: [String: Any] = [
156
+ "status": status,
157
+ "statusText": HTTPURLResponse.localizedString(forStatusCode: status),
158
+ "headers": headersDict,
159
+ "ok": (200..<300).contains(status),
160
+ ]
161
+ // The string `body` only when the bytes are exactly its UTF-8
162
+ // encoding; otherwise every byte as base64. The decode fails
163
+ // on invalid UTF-8, and the round trip also catches a dropped
164
+ // byte order mark.
165
+ let body = data ?? Data()
166
+ if let text = String(data: body, encoding: .utf8), Data(text.utf8) == body {
167
+ result["body"] = text
168
+ } else {
169
+ result["bodyBase64"] = body.base64EncodedString()
170
+ }
148
171
  let payload: [String: Any] = [
149
172
  "kind": "response",
150
173
  "reqId": reqId,
151
174
  "ok": true,
152
- "result": [
153
- "status": status,
154
- "statusText": HTTPURLResponse.localizedString(forStatusCode: status),
155
- "headers": headersDict,
156
- "body": bodyStr,
157
- "ok": (200..<300).contains(status),
158
- ] as [String: Any],
175
+ "result": result,
159
176
  ]
160
177
  JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: payload)
161
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/crust",
3
- "version": "3.3.0-dev.943",
3
+ "version": "3.3.0",
4
4
  "description": "Mentra Native Module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -35,7 +35,7 @@
35
35
  "license": "Apache-2.0",
36
36
  "homepage": "https://github.com/Mentra-Community/MentraOS/tree/dev/mobile/modules/crust#readme",
37
37
  "dependencies": {
38
- "@mentra/jspolyfill": "3.3.0-dev.943"
38
+ "@mentra/jspolyfill": "3.3.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@expo/config-plugins": ">=8.0.0",