@mentra/crust 3.4.0-dev.960 → 3.4.0-dev.965
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/android/build.gradle +9 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +68 -29
- package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +199 -0
- package/ios/Resources/startup.js +154 -68
- package/ios/Source/JSCPolyfillBridge.swift +26 -12
- package/package.json +2 -2
package/android/build.gradle
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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,199 @@
|
|
|
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
|
+
// crust's build.gradle ships this file as the runtime's startup.js asset.
|
|
58
|
+
private val polyfill: String by lazy { File("../../jspolyfill/assets/startup.js").readText() }
|
|
59
|
+
|
|
60
|
+
@Before fun setUp() {
|
|
61
|
+
server = MockWebServer().apply {
|
|
62
|
+
dispatcher = object : Dispatcher() {
|
|
63
|
+
override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) {
|
|
64
|
+
"/reply.pcm" -> response("application/octet-stream").setBody(Buffer().write(pcm))
|
|
65
|
+
"/utf8.bin" -> response("application/octet-stream").setBody(Buffer().write(utf8Bytes))
|
|
66
|
+
"/data.json" -> response("application/json").setBody("{\"message\":\"héllo 😀 中文\"}")
|
|
67
|
+
"/upload" -> response("text/plain").setBody("ok")
|
|
68
|
+
else -> MockResponse().setResponseCode(404)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
start()
|
|
72
|
+
}
|
|
73
|
+
runtime = JSCRuntime.shared(RuntimeEnvironment.getApplication())
|
|
74
|
+
JSCPolyfillBridge.install(runtime)
|
|
75
|
+
runtime.onOutbound = { message ->
|
|
76
|
+
if (message.payload["iface"] == "test") {
|
|
77
|
+
results.add((message.payload["args"] as List<*>).first() as Map<*, *>)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@After fun tearDown() {
|
|
83
|
+
runtime.kill(packageName)
|
|
84
|
+
runtime.onOutbound = null
|
|
85
|
+
server.shutdown()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test fun `arrayBuffer returns the exact bytes of a binary response`() {
|
|
89
|
+
val result = runMiniapp(
|
|
90
|
+
"""
|
|
91
|
+
const response = await fetch(BASE + "/reply.pcm")
|
|
92
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
93
|
+
return {status: response.status, type: response.headers.get("content-type"), bytes: Array.from(bytes)}
|
|
94
|
+
""",
|
|
95
|
+
)
|
|
96
|
+
assertEquals(200, result["status"])
|
|
97
|
+
assertEquals("application/octet-stream", result["type"])
|
|
98
|
+
assertArrayEquals(pcm, bytes(result["bytes"]))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test fun `arrayBuffer returns the exact bytes of a body that is valid UTF-8`() {
|
|
102
|
+
val result = runMiniapp(
|
|
103
|
+
"""
|
|
104
|
+
const response = await fetch(BASE + "/utf8.bin")
|
|
105
|
+
return {bytes: Array.from(new Uint8Array(await response.arrayBuffer()))}
|
|
106
|
+
""",
|
|
107
|
+
)
|
|
108
|
+
assertArrayEquals(utf8Bytes, bytes(result["bytes"]))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test fun `responses are sent as text only when that text re-encodes to the same bytes`() {
|
|
112
|
+
val text = JSCPolyfillBridge.fetchResult(responseWith(utf8Bytes))
|
|
113
|
+
assertEquals(String(utf8Bytes, Charsets.UTF_8), text.getString("body"))
|
|
114
|
+
assertFalse(text.has("bodyBase64"))
|
|
115
|
+
|
|
116
|
+
val empty = JSCPolyfillBridge.fetchResult(responseWith(ByteArray(0)))
|
|
117
|
+
assertEquals("", empty.getString("body"))
|
|
118
|
+
|
|
119
|
+
// Truncated, overlong, surrogate and out-of-range sequences, then raw PCM.
|
|
120
|
+
for (binary in listOf(byteArrayOf(0x41, 0xE2.toByte(), 0x82.toByte()), byteArrayOf(0xC0.toByte(), 0x80.toByte()),
|
|
121
|
+
byteArrayOf(0xED.toByte(), 0xA0.toByte(), 0x80.toByte()), byteArrayOf(0xF4.toByte(), 0x90.toByte(), 0x80.toByte(), 0x80.toByte()), pcm)) {
|
|
122
|
+
val result = JSCPolyfillBridge.fetchResult(responseWith(binary))
|
|
123
|
+
assertFalse(result.has("body"))
|
|
124
|
+
assertArrayEquals(binary, result.getString("bodyBase64").decodeBase64()!!.toByteArray())
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Test fun `text and json decode a UTF-8 response`() {
|
|
129
|
+
val result = runMiniapp(
|
|
130
|
+
"""
|
|
131
|
+
const text = await (await fetch(BASE + "/data.json")).text()
|
|
132
|
+
const json = await (await fetch(BASE + "/data.json")).json()
|
|
133
|
+
return {text, message: json.message}
|
|
134
|
+
""",
|
|
135
|
+
)
|
|
136
|
+
assertEquals("{\"message\":\"héllo 😀 中文\"}", result["text"])
|
|
137
|
+
assertEquals("héllo 😀 中文", result["message"])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test fun `binary request bodies reach the server byte for byte`() {
|
|
141
|
+
val result = runMiniapp(
|
|
142
|
+
"""
|
|
143
|
+
const backing = Uint8Array.from({length: 600}, (_, i) => (i * 37 + 11) & 255)
|
|
144
|
+
const response = await fetch(BASE + "/upload", {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {"Content-Type": "audio/L16; rate=24000"},
|
|
147
|
+
body: backing.subarray(44),
|
|
148
|
+
})
|
|
149
|
+
return {status: response.status, text: await response.text()}
|
|
150
|
+
""",
|
|
151
|
+
)
|
|
152
|
+
assertEquals(200, result["status"])
|
|
153
|
+
assertEquals("ok", result["text"])
|
|
154
|
+
val upload = server.takeRequest()
|
|
155
|
+
val expected = ByteArray(600) { ((it * 37 + 11) and 255).toByte() }.copyOfRange(44, 600)
|
|
156
|
+
assertArrayEquals(expected, upload.body.readByteArray())
|
|
157
|
+
assertEquals("audio/L16; rate=24000", upload.getHeader("Content-Type"))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@Test fun `string request bodies keep their UTF-8 text and content type`() {
|
|
161
|
+
runMiniapp(
|
|
162
|
+
"""
|
|
163
|
+
await fetch(BASE + "/upload", {method: "POST", headers: {"Content-Type": "application/json"}, body: '{"t":"é😀"}'})
|
|
164
|
+
return {}
|
|
165
|
+
""",
|
|
166
|
+
)
|
|
167
|
+
val upload = server.takeRequest()
|
|
168
|
+
assertEquals("{\"t\":\"é😀\"}", upload.body.readUtf8())
|
|
169
|
+
assertEquals("application/json; charset=utf-8", upload.getHeader("Content-Type"))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private fun runMiniapp(body: String): Map<*, *> {
|
|
173
|
+
val script =
|
|
174
|
+
"""
|
|
175
|
+
const BASE = "${server.url("/").toString().removeSuffix("/")}"
|
|
176
|
+
const report = (value) => __mentraSendOneShot("test", "result", [value])
|
|
177
|
+
;(async () => {
|
|
178
|
+
$body
|
|
179
|
+
})().then(report, (error) => report({error: String(error && (error.message || error))}))
|
|
180
|
+
""".trimIndent()
|
|
181
|
+
assertTrue("spawn failed", runtime.spawn(packageName, polyfill, script))
|
|
182
|
+
val result = results.poll(15, TimeUnit.SECONDS) ?: throw AssertionError("miniapp did not report a result")
|
|
183
|
+
assertNull("miniapp script threw", result["error"])
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fun bytes(value: Any?): ByteArray =
|
|
188
|
+
(value as List<*>).map { (it as Number).toInt().toByte() }.toByteArray()
|
|
189
|
+
|
|
190
|
+
private fun response(contentType: String) = MockResponse().setHeader("Content-Type", contentType)
|
|
191
|
+
|
|
192
|
+
private fun responseWith(body: ByteArray): Response = Response.Builder()
|
|
193
|
+
.request(Request.Builder().url("https://example.com/body").build())
|
|
194
|
+
.protocol(Protocol.HTTP_1_1)
|
|
195
|
+
.code(200)
|
|
196
|
+
.message("OK")
|
|
197
|
+
.body(body.toResponseBody("application/octet-stream".toMediaType()))
|
|
198
|
+
.build()
|
|
199
|
+
}
|
package/ios/Resources/startup.js
CHANGED
|
@@ -436,6 +436,140 @@
|
|
|
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") {
|
|
440
574
|
class TextEncoderPolyfill {
|
|
441
575
|
constructor() {
|
|
@@ -476,36 +610,14 @@
|
|
|
476
610
|
}
|
|
477
611
|
if (typeof g.TextDecoder !== "function") {
|
|
478
612
|
class TextDecoderPolyfill {
|
|
479
|
-
constructor(encoding = "utf-8") {
|
|
613
|
+
constructor(encoding = "utf-8", options) {
|
|
480
614
|
this.encoding = encoding;
|
|
615
|
+
this.fatal = options?.fatal === true;
|
|
616
|
+
this.ignoreBOM = options?.ignoreBOM === true;
|
|
481
617
|
}
|
|
482
618
|
decode(buffer) {
|
|
483
619
|
if (!buffer) return "";
|
|
484
|
-
|
|
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;
|
|
620
|
+
return decodeUtf8(bytesOf(buffer), this.fatal, this.ignoreBOM);
|
|
509
621
|
}
|
|
510
622
|
}
|
|
511
623
|
;
|
|
@@ -552,9 +664,12 @@
|
|
|
552
664
|
const url = typeof input === "string" ? input : input.toString();
|
|
553
665
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
554
666
|
let bodyString = null;
|
|
667
|
+
let bodyBase64;
|
|
555
668
|
const reqBody = init?.body;
|
|
556
669
|
if (typeof reqBody === "string") {
|
|
557
670
|
bodyString = reqBody;
|
|
671
|
+
} else if (reqBody instanceof ArrayBuffer || ArrayBuffer.isView(reqBody)) {
|
|
672
|
+
bodyBase64 = bytesToBase64(bytesOf(reqBody));
|
|
558
673
|
} else if (reqBody && typeof reqBody.toString === "function") {
|
|
559
674
|
bodyString = reqBody.toString();
|
|
560
675
|
}
|
|
@@ -578,9 +693,10 @@
|
|
|
578
693
|
}
|
|
579
694
|
const sendRequest = g.__mentraSendRequest;
|
|
580
695
|
const result = await sendRequest("fetch", "request", [
|
|
581
|
-
{ url, method, headers, body: bodyString }
|
|
696
|
+
bodyBase64 === void 0 ? { url, method, headers, body: bodyString } : { url, method, headers, bodyBase64 }
|
|
582
697
|
]);
|
|
583
|
-
const
|
|
698
|
+
const responseText = typeof result.body === "string" ? result.body : null;
|
|
699
|
+
const responseBase64 = typeof result.bodyBase64 === "string" ? result.bodyBase64 : "";
|
|
584
700
|
const responseHeaders = new Map(Object.entries(result.headers ?? {}));
|
|
585
701
|
class ResponseLike {
|
|
586
702
|
constructor() {
|
|
@@ -597,11 +713,13 @@
|
|
|
597
713
|
};
|
|
598
714
|
}
|
|
599
715
|
async text() {
|
|
600
|
-
return
|
|
716
|
+
if (responseText === null) return decodeUtf8(base64ToBytes(responseBase64), false, false);
|
|
717
|
+
return responseText.charCodeAt(0) === 65279 ? responseText.slice(1) : responseText;
|
|
601
718
|
}
|
|
602
719
|
async json() {
|
|
720
|
+
const text = await this.text();
|
|
603
721
|
try {
|
|
604
|
-
return JSON.parse(
|
|
722
|
+
return JSON.parse(text);
|
|
605
723
|
} catch (e) {
|
|
606
724
|
const err = new Error(
|
|
607
725
|
`Failed to parse JSON response from ${url}: ${e instanceof Error ? e.message : String(e)}`
|
|
@@ -611,52 +729,21 @@
|
|
|
611
729
|
}
|
|
612
730
|
}
|
|
613
731
|
async arrayBuffer() {
|
|
732
|
+
if (responseText === null) return base64ToBytes(responseBase64).buffer;
|
|
614
733
|
const enc = new g.TextEncoder();
|
|
615
|
-
return enc.encode(
|
|
734
|
+
return enc.encode(responseText).buffer;
|
|
616
735
|
}
|
|
617
736
|
}
|
|
618
737
|
return new ResponseLike();
|
|
619
738
|
};
|
|
620
739
|
}
|
|
621
740
|
if (typeof g.WebSocket !== "function") {
|
|
622
|
-
let
|
|
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) {
|
|
741
|
+
let queueMicrotaskSafe2 = function(fn) {
|
|
655
742
|
const q = g.queueMicrotask;
|
|
656
743
|
if (typeof q === "function") q(fn);
|
|
657
744
|
else g.Promise.resolve().then(fn);
|
|
658
745
|
};
|
|
659
|
-
var
|
|
746
|
+
var queueMicrotaskSafe = queueMicrotaskSafe2;
|
|
660
747
|
const sockets = /* @__PURE__ */ new Map();
|
|
661
748
|
g.__mentraDeliverWebSocketEvent = (sid, type, payload) => {
|
|
662
749
|
const sock = sockets.get(sid);
|
|
@@ -718,8 +805,7 @@
|
|
|
718
805
|
payload = data;
|
|
719
806
|
} else {
|
|
720
807
|
kind = "binary";
|
|
721
|
-
|
|
722
|
-
payload = bytesToBase642(bytes);
|
|
808
|
+
payload = bytesToBase64(bytesOf(data));
|
|
723
809
|
}
|
|
724
810
|
try {
|
|
725
811
|
__dispatch("ws", "send", JSON.stringify([{ sid: this.sid, kind, payload }]));
|
|
@@ -752,7 +838,7 @@
|
|
|
752
838
|
}
|
|
753
839
|
const synth = { type, target: this, ...ev };
|
|
754
840
|
if (type === "message" && ev.kind === "binary" && typeof ev.data === "string") {
|
|
755
|
-
synth.data =
|
|
841
|
+
synth.data = base64ToBytes(ev.data).buffer;
|
|
756
842
|
} else if (type === "message" && ev.kind === "text") {
|
|
757
843
|
synth.data = ev.data;
|
|
758
844
|
}
|
|
@@ -106,7 +106,15 @@ public enum JSCPolyfillBridge {
|
|
|
106
106
|
urlRequest.setValue(String(describing: v), forHTTPHeaderField: k)
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
-
|
|
109
|
+
// ArrayBuffer and typed-array bodies arrive as base64 of their bytes.
|
|
110
|
+
if let encoded = req["bodyBase64"] as? String {
|
|
111
|
+
guard let data = Data(base64Encoded: encoded) else {
|
|
112
|
+
return .error(code: "INVALID_ARGS", message: "fetch.request: bodyBase64 is not base64")
|
|
113
|
+
}
|
|
114
|
+
if !data.isEmpty {
|
|
115
|
+
urlRequest.httpBody = data
|
|
116
|
+
}
|
|
117
|
+
} else if let body = req["body"] as? String, !body.isEmpty {
|
|
110
118
|
urlRequest.httpBody = body.data(using: .utf8)
|
|
111
119
|
}
|
|
112
120
|
|
|
@@ -141,21 +149,27 @@ public enum JSCPolyfillBridge {
|
|
|
141
149
|
}
|
|
142
150
|
return out
|
|
143
151
|
}()
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
var result: [String: Any] = [
|
|
153
|
+
"status": status,
|
|
154
|
+
"statusText": HTTPURLResponse.localizedString(forStatusCode: status),
|
|
155
|
+
"headers": headersDict,
|
|
156
|
+
"ok": (200..<300).contains(status),
|
|
157
|
+
]
|
|
158
|
+
// The string `body` only when the bytes are exactly its UTF-8
|
|
159
|
+
// encoding; otherwise every byte as base64. The decode fails
|
|
160
|
+
// on invalid UTF-8, and the round trip also catches a dropped
|
|
161
|
+
// byte order mark.
|
|
162
|
+
let body = data ?? Data()
|
|
163
|
+
if let text = String(data: body, encoding: .utf8), Data(text.utf8) == body {
|
|
164
|
+
result["body"] = text
|
|
165
|
+
} else {
|
|
166
|
+
result["bodyBase64"] = body.base64EncodedString()
|
|
167
|
+
}
|
|
148
168
|
let payload: [String: Any] = [
|
|
149
169
|
"kind": "response",
|
|
150
170
|
"reqId": reqId,
|
|
151
171
|
"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],
|
|
172
|
+
"result": result,
|
|
159
173
|
]
|
|
160
174
|
JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: payload)
|
|
161
175
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/crust",
|
|
3
|
-
"version": "3.4.0-dev.
|
|
3
|
+
"version": "3.4.0-dev.965",
|
|
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.4.0-dev.
|
|
38
|
+
"@mentra/jspolyfill": "3.4.0-dev.965"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@expo/config-plugins": ">=8.0.0",
|