@mentra/crust 3.2.1-dev.314 → 3.3.0-beta.942
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 +27 -0
- package/android/build.gradle +18 -9
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +76 -22
- package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
- package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +6 -35
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
- package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +74 -10
- package/android/src/main/java/com/mentra/crust/navigation/NavigationSessionGuard.kt +74 -0
- package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
- package/android/src/main/java/com/mentra/crust/services/RuntimeRecoveryService.kt +12 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
- package/android/src/test/java/com/mentra/crust/jsc/HttpRequestTest.kt +32 -0
- package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
- package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
- package/android/src/test/java/com/mentra/crust/navigation/NavigationSessionGuardTest.kt +68 -0
- package/build/Crust.types.d.ts +25 -11
- package/build/Crust.types.d.ts.map +1 -1
- package/build/Crust.types.js.map +1 -1
- package/build/CrustModule.d.ts +11 -2
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +3 -1
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +20 -8
- package/build/CrustModule.web.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/nativeHttpRequest.d.ts +13 -0
- package/build/nativeHttpRequest.d.ts.map +1 -0
- package/build/nativeHttpRequest.js +51 -0
- package/build/nativeHttpRequest.js.map +1 -0
- package/build/nativeHttpRequest.test.d.ts +2 -0
- package/build/nativeHttpRequest.test.d.ts.map +1 -0
- package/build/nativeHttpRequest.test.js +88 -0
- package/build/nativeHttpRequest.test.js.map +1 -0
- package/ios/Crust.podspec +1 -1
- package/ios/CrustModule.swift +22 -7
- package/ios/PermissionStatusReceipt.swift +105 -0
- package/package.json +2 -2
- package/plugin/build/index.d.ts +2 -0
- package/plugin/build/index.js +2 -0
- package/plugin/build/withAndroid.js +39 -0
- package/src/Crust.types.ts +26 -12
- package/src/CrustModule.ts +11 -1
- package/src/CrustModule.web.ts +18 -7
- package/src/index.ts +1 -0
- package/src/nativeHttpRequest.test.ts +94 -0
- package/src/nativeHttpRequest.ts +74 -0
- package/android/src/internal/AndroidManifest.xml +0 -9
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +0 -46
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { nativeHttpRequest } from "./nativeHttpRequest";
|
|
2
|
+
const response = { status: 200, statusText: "OK", headers: {}, body: "{}" };
|
|
3
|
+
function deferred() {
|
|
4
|
+
let resolve;
|
|
5
|
+
let reject;
|
|
6
|
+
const promise = new Promise((yes, no) => {
|
|
7
|
+
resolve = yes;
|
|
8
|
+
reject = no;
|
|
9
|
+
});
|
|
10
|
+
return { promise, resolve, reject };
|
|
11
|
+
}
|
|
12
|
+
function transport(request = deferred()) {
|
|
13
|
+
const native = {
|
|
14
|
+
nativeHttpRequest: jest.fn(() => request.promise),
|
|
15
|
+
cancelNativeHttpRequest: jest.fn(async () => { }),
|
|
16
|
+
};
|
|
17
|
+
return { native, request };
|
|
18
|
+
}
|
|
19
|
+
describe("nativeHttpRequest", () => {
|
|
20
|
+
test("pre-aborted requests do not start or cancel native work", async () => {
|
|
21
|
+
const { native } = transport();
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const reason = new Error("deadline");
|
|
24
|
+
controller.abort(reason);
|
|
25
|
+
await expect(nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal)).rejects.toBe(reason);
|
|
26
|
+
expect(native.nativeHttpRequest).not.toHaveBeenCalled();
|
|
27
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
test("abort cancels only this request and waits for native cancellation acceptance", async () => {
|
|
30
|
+
const { native, request } = transport();
|
|
31
|
+
const cancellation = deferred();
|
|
32
|
+
native.cancelNativeHttpRequest = jest.fn(() => cancellation.promise);
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const remove = jest.spyOn(controller.signal, "removeEventListener");
|
|
35
|
+
const promise = nativeHttpRequest(native, "POST", "https://example.com", { test: "header" }, "body", controller.signal);
|
|
36
|
+
const rejected = expect(promise).rejects.toMatchObject({ name: "AbortError" });
|
|
37
|
+
controller.abort();
|
|
38
|
+
const id = native.nativeHttpRequest.mock.calls[0][0];
|
|
39
|
+
expect(native.cancelNativeHttpRequest).toHaveBeenCalledWith(id);
|
|
40
|
+
expect(remove).not.toHaveBeenCalled();
|
|
41
|
+
request.reject(new Error("cancelled socket"));
|
|
42
|
+
cancellation.resolve();
|
|
43
|
+
await rejected;
|
|
44
|
+
expect(native.cancelNativeHttpRequest).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
|
|
46
|
+
});
|
|
47
|
+
test("an abort during native start is cancelled after start and removes its listener", async () => {
|
|
48
|
+
const { native } = transport();
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const order = [];
|
|
51
|
+
native.nativeHttpRequest = jest.fn(() => {
|
|
52
|
+
order.push("start");
|
|
53
|
+
controller.abort();
|
|
54
|
+
return new Promise(() => { });
|
|
55
|
+
});
|
|
56
|
+
native.cancelNativeHttpRequest = jest.fn(async () => { order.push("cancel"); });
|
|
57
|
+
await expect(nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
|
|
58
|
+
expect(order).toEqual(["start", "cancel"]);
|
|
59
|
+
});
|
|
60
|
+
test.each(["success", "failure"])("%s removes its abort listener and a later abort does not cancel", async (outcome) => {
|
|
61
|
+
const { native, request } = transport();
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const remove = jest.spyOn(controller.signal, "removeEventListener");
|
|
64
|
+
const promise = nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal);
|
|
65
|
+
if (outcome === "success") {
|
|
66
|
+
request.resolve(response);
|
|
67
|
+
await expect(promise).resolves.toBe(response);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
request.reject(new Error("offline"));
|
|
71
|
+
await expect(promise).rejects.toThrow("offline");
|
|
72
|
+
}
|
|
73
|
+
controller.abort();
|
|
74
|
+
expect(remove).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
test("independent calls have separate IDs and unsignalled calls keep the native path", async () => {
|
|
78
|
+
const { native, request } = transport();
|
|
79
|
+
const first = nativeHttpRequest(native, "GET", "https://example.com/first", {}, null);
|
|
80
|
+
const second = nativeHttpRequest(native, "GET", "https://example.com/second", {}, null);
|
|
81
|
+
const calls = native.nativeHttpRequest.mock.calls;
|
|
82
|
+
expect(calls[0][0]).not.toBe(calls[1][0]);
|
|
83
|
+
request.resolve(response);
|
|
84
|
+
await Promise.all([first, second]);
|
|
85
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
//# sourceMappingURL=nativeHttpRequest.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nativeHttpRequest.test.js","sourceRoot":"","sources":["../src/nativeHttpRequest.test.ts"],"names":[],"mappings":"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","sourcesContent":["import {nativeHttpRequest, type NativeHttpResult, type NativeHttpTransport} from \"./nativeHttpRequest\"\n\nconst response: NativeHttpResult = {status: 200, statusText: \"OK\", headers: {}, body: \"{}\"}\n\nfunction deferred<T>() {\n let resolve!: (value: T) => void\n let reject!: (error: unknown) => void\n const promise = new Promise<T>((yes, no) => {\n resolve = yes\n reject = no\n })\n return {promise, resolve, reject}\n}\n\nfunction transport(request = deferred<NativeHttpResult>()) {\n const native: NativeHttpTransport = {\n nativeHttpRequest: jest.fn(() => request.promise),\n cancelNativeHttpRequest: jest.fn(async () => {}),\n }\n return {native, request}\n}\n\ndescribe(\"nativeHttpRequest\", () => {\n test(\"pre-aborted requests do not start or cancel native work\", async () => {\n const {native} = transport()\n const controller = new AbortController()\n const reason = new Error(\"deadline\")\n controller.abort(reason)\n await expect(nativeHttpRequest(native, \"GET\", \"https://example.com\", {}, null, controller.signal)).rejects.toBe(reason)\n expect(native.nativeHttpRequest).not.toHaveBeenCalled()\n expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()\n })\n\n test(\"abort cancels only this request and waits for native cancellation acceptance\", async () => {\n const {native, request} = transport()\n const cancellation = deferred<void>()\n native.cancelNativeHttpRequest = jest.fn(() => cancellation.promise)\n const controller = new AbortController()\n const remove = jest.spyOn(controller.signal, \"removeEventListener\")\n const promise = nativeHttpRequest(native, \"POST\", \"https://example.com\", {test: \"header\"}, \"body\", controller.signal)\n const rejected = expect(promise).rejects.toMatchObject({name: \"AbortError\"})\n controller.abort()\n const id = (native.nativeHttpRequest as jest.Mock).mock.calls[0][0]\n expect(native.cancelNativeHttpRequest).toHaveBeenCalledWith(id)\n expect(remove).not.toHaveBeenCalled()\n request.reject(new Error(\"cancelled socket\"))\n cancellation.resolve()\n await rejected\n expect(native.cancelNativeHttpRequest).toHaveBeenCalledTimes(1)\n expect(remove).toHaveBeenCalledWith(\"abort\", expect.any(Function))\n })\n\n test(\"an abort during native start is cancelled after start and removes its listener\", async () => {\n const {native} = transport()\n const controller = new AbortController()\n const order: string[] = []\n native.nativeHttpRequest = jest.fn(() => {\n order.push(\"start\")\n controller.abort()\n return new Promise<NativeHttpResult>(() => {})\n })\n native.cancelNativeHttpRequest = jest.fn(async () => {order.push(\"cancel\")})\n await expect(nativeHttpRequest(native, \"GET\", \"https://example.com\", {}, null, controller.signal)).rejects.toMatchObject({name: \"AbortError\"})\n expect(order).toEqual([\"start\", \"cancel\"])\n })\n\n test.each([\"success\", \"failure\"])(\"%s removes its abort listener and a later abort does not cancel\", async (outcome) => {\n const {native, request} = transport()\n const controller = new AbortController()\n const remove = jest.spyOn(controller.signal, \"removeEventListener\")\n const promise = nativeHttpRequest(native, \"GET\", \"https://example.com\", {}, null, controller.signal)\n if (outcome === \"success\") {\n request.resolve(response)\n await expect(promise).resolves.toBe(response)\n } else {\n request.reject(new Error(\"offline\"))\n await expect(promise).rejects.toThrow(\"offline\")\n }\n controller.abort()\n expect(remove).toHaveBeenCalledTimes(1)\n expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()\n })\n\n test(\"independent calls have separate IDs and unsignalled calls keep the native path\", async () => {\n const {native, request} = transport()\n const first = nativeHttpRequest(native, \"GET\", \"https://example.com/first\", {}, null)\n const second = nativeHttpRequest(native, \"GET\", \"https://example.com/second\", {}, null)\n const calls = (native.nativeHttpRequest as jest.Mock).mock.calls\n expect(calls[0][0]).not.toBe(calls[1][0])\n request.resolve(response)\n await Promise.all([first, second])\n expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()\n })\n})\n"]}
|
package/ios/Crust.podspec
CHANGED
|
@@ -29,7 +29,7 @@ Pod::Spec.new do |s|
|
|
|
29
29
|
# is intentionally NO `s.dependency 'MapboxNavigationCore'` here.
|
|
30
30
|
|
|
31
31
|
# iOS frameworks required for media processing + navigation + heading
|
|
32
|
-
s.frameworks = 'AVFoundation', 'Photos', 'CoreImage', 'CoreGraphics', 'UIKit', 'CoreLocation', 'CoreMotion'
|
|
32
|
+
s.frameworks = 'AVFoundation', 'Photos', 'CoreImage', 'CoreGraphics', 'UIKit', 'CoreLocation', 'CoreMotion', 'CoreBluetooth'
|
|
33
33
|
|
|
34
34
|
# Swift/Objective-C compatibility + Mapbox SPM module visibility.
|
|
35
35
|
#
|
package/ios/CrustModule.swift
CHANGED
|
@@ -61,10 +61,26 @@ private struct PhotoLibrarySaveResult {
|
|
|
61
61
|
let timedOut: Bool
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/// MentraJS calls get their own serial queue. Expo runs every module's default-queue
|
|
65
|
+
/// AsyncFunction on one shared serial queue, so a blocking call in another module held every
|
|
66
|
+
/// host→miniapp message behind it: the ~25s offline STT model extraction after login kept
|
|
67
|
+
/// miniapps from spawning and starved running ones of PINGs until the host respawned them.
|
|
68
|
+
/// Serial, so spawn, dispatch and kill still run in the order JS issued them.
|
|
69
|
+
private let mentraJsQueue = DispatchQueue(label: "com.mentra.mentrajs-bridge", qos: .userInitiated)
|
|
70
|
+
|
|
64
71
|
public class CrustModule: Module {
|
|
65
72
|
public func definition() -> ModuleDefinition {
|
|
66
73
|
Name("Crust")
|
|
67
74
|
|
|
75
|
+
Constant("isIOSAppOnMac") {
|
|
76
|
+
ProcessInfo.processInfo.isiOSAppOnMac
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
AsyncFunction("getPermissionStatus") { (requestId: String?) -> [String: Any] in
|
|
80
|
+
if let requestId { return try await PermissionStatusReceipt.publish(requestId: requestId) }
|
|
81
|
+
return await PermissionStatusReceipt.current()
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
Constant("PI") {
|
|
69
85
|
Double.pi
|
|
70
86
|
}
|
|
@@ -73,7 +89,6 @@ public class CrustModule: Module {
|
|
|
73
89
|
"onChange",
|
|
74
90
|
"phone_notification",
|
|
75
91
|
"phone_notification_dismissed",
|
|
76
|
-
"captions_tester_incident",
|
|
77
92
|
"onNavManeuver",
|
|
78
93
|
"onNavRerouting",
|
|
79
94
|
"onNavArrived",
|
|
@@ -279,7 +294,7 @@ public class CrustModule: Module {
|
|
|
279
294
|
polyfillBundle: polyfillBundle,
|
|
280
295
|
miniappJs: miniappJs
|
|
281
296
|
)
|
|
282
|
-
}
|
|
297
|
+
}.runOnQueue(mentraJsQueue)
|
|
283
298
|
|
|
284
299
|
/// Evaluate an arbitrary script inside the named context. Returns
|
|
285
300
|
/// the JS return value bridged to a JSON-friendly Swift type, or
|
|
@@ -287,19 +302,19 @@ public class CrustModule: Module {
|
|
|
287
302
|
/// + tests; production code paths use mentraJsDispatchToJs.
|
|
288
303
|
AsyncFunction("mentraJsEvaluate") { (packageName: String, source: String) -> Any? in
|
|
289
304
|
return JSCRuntime.shared.evaluate(packageName: packageName, source: source)
|
|
290
|
-
}
|
|
305
|
+
}.runOnQueue(mentraJsQueue)
|
|
291
306
|
|
|
292
307
|
/// Tear down a JS context. Cancels timers, drops refs, forces GC.
|
|
293
308
|
AsyncFunction("mentraJsKill") { (packageName: String) -> Void in
|
|
294
309
|
JSCRuntime.shared.kill(packageName: packageName)
|
|
295
|
-
}
|
|
310
|
+
}.runOnQueue(mentraJsQueue)
|
|
296
311
|
|
|
297
312
|
/// Push an event / response envelope into the named context's
|
|
298
313
|
/// globalThis.__deliver. Used by MentraJSRouter for
|
|
299
314
|
/// glasses-status broadcasts and request/response correlation.
|
|
300
315
|
AsyncFunction("mentraJsDispatchToJs") { (packageName: String, envelope: [String: Any]) -> Void in
|
|
301
316
|
JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: envelope)
|
|
302
|
-
}
|
|
317
|
+
}.runOnQueue(mentraJsQueue)
|
|
303
318
|
|
|
304
319
|
/// Set the installed manifest for a miniapp so the dispatcher's
|
|
305
320
|
/// permission gate can authorize sensitive `__dispatch` calls.
|
|
@@ -308,7 +323,7 @@ public class CrustModule: Module {
|
|
|
308
323
|
packageName: packageName,
|
|
309
324
|
manifest: InstalledMiniappManifest(permissions: Set(permissions))
|
|
310
325
|
)
|
|
311
|
-
}
|
|
326
|
+
}.runOnQueue(mentraJsQueue)
|
|
312
327
|
|
|
313
328
|
/// Diagnostic: list all live packageNames.
|
|
314
329
|
Function("mentraJsAlivePackages") { () -> [String] in
|
|
@@ -320,7 +335,7 @@ public class CrustModule: Module {
|
|
|
320
335
|
/// the context is dead.
|
|
321
336
|
AsyncFunction("mentraJsDebugForceGC") { (packageName: String) -> Bool in
|
|
322
337
|
return JSCRuntime.shared.debugForceGC(packageName: packageName)
|
|
323
|
-
}
|
|
338
|
+
}.runOnQueue(mentraJsQueue)
|
|
324
339
|
|
|
325
340
|
/// Read the bundled MentraJS polyfill (startup.js) from the iOS
|
|
326
341
|
/// pod's resource bundle. The host calls this once on app boot,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import CoreBluetooth
|
|
3
|
+
import CoreLocation
|
|
4
|
+
import Foundation
|
|
5
|
+
|
|
6
|
+
/// Passive app-owned observations. None of these getters requests consent,
|
|
7
|
+
/// starts capture, creates a Bluetooth central, or joins a network.
|
|
8
|
+
enum PermissionStatusReceipt {
|
|
9
|
+
static let relativePath = "MentraDiagnostics/permission-status.json"
|
|
10
|
+
|
|
11
|
+
static func state(_ rawStatus: String, _ status: String) -> [String: String] {
|
|
12
|
+
["rawStatus": rawStatus, "status": status]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static func location(_ value: CLAuthorizationStatus) -> [String: String] {
|
|
16
|
+
switch value {
|
|
17
|
+
case .notDetermined: return state("notDetermined", "denied")
|
|
18
|
+
case .denied: return state("denied", "blocked")
|
|
19
|
+
case .restricted: return state("restricted", "unavailable")
|
|
20
|
+
case .authorizedAlways: return state("authorizedAlways", "granted")
|
|
21
|
+
case .authorizedWhenInUse: return state("authorizedWhenInUse", "granted")
|
|
22
|
+
@unknown default: return state("unknown", "unknown")
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static func capture(_ value: AVAuthorizationStatus) -> [String: String] {
|
|
27
|
+
switch value {
|
|
28
|
+
case .notDetermined: return state("notDetermined", "denied")
|
|
29
|
+
case .denied: return state("denied", "blocked")
|
|
30
|
+
case .restricted: return state("restricted", "unavailable")
|
|
31
|
+
case .authorized: return state("authorized", "granted")
|
|
32
|
+
@unknown default: return state("unknown", "unknown")
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static func bluetooth(_ value: CBManagerAuthorization) -> [String: String] {
|
|
37
|
+
switch value {
|
|
38
|
+
case .notDetermined: return state("notDetermined", "denied")
|
|
39
|
+
case .denied: return state("denied", "blocked")
|
|
40
|
+
case .restricted: return state("restricted", "unavailable")
|
|
41
|
+
case .allowedAlways: return state("allowedAlways", "granted")
|
|
42
|
+
@unknown default: return state("unknown", "unknown")
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static func current() async -> [String: Any] {
|
|
47
|
+
// The global service getter may block; only the manager's authorization
|
|
48
|
+
// observation belongs to the main actor, which owns Core Location objects.
|
|
49
|
+
let servicesEnabled = CLLocationManager.locationServicesEnabled()
|
|
50
|
+
return await MainActor.run {
|
|
51
|
+
let bundle = Bundle.main
|
|
52
|
+
let formatter = ISO8601DateFormatter()
|
|
53
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
54
|
+
return [
|
|
55
|
+
"schemaVersion": 1,
|
|
56
|
+
"observedAt": formatter.string(from: Date()),
|
|
57
|
+
"identity": [
|
|
58
|
+
"pid": ProcessInfo.processInfo.processIdentifier,
|
|
59
|
+
"uid": getuid(),
|
|
60
|
+
"bundleId": bundle.bundleIdentifier ?? "",
|
|
61
|
+
"bundlePath": bundle.bundleURL.path,
|
|
62
|
+
"executablePath": bundle.executableURL?.path ?? "",
|
|
63
|
+
"version": bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "",
|
|
64
|
+
"build": bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "",
|
|
65
|
+
] as [String: Any],
|
|
66
|
+
"locationServicesEnabled": servicesEnabled,
|
|
67
|
+
"permissions": [
|
|
68
|
+
"location": location(CLLocationManager().authorizationStatus),
|
|
69
|
+
"camera": capture(AVCaptureDevice.authorizationStatus(for: .video)),
|
|
70
|
+
"microphone": capture(AVCaptureDevice.authorizationStatus(for: .audio)),
|
|
71
|
+
"bluetooth": bluetooth(CBManager.authorization),
|
|
72
|
+
// iOS has no public passive Local Network authorization getter.
|
|
73
|
+
"localNetwork": ["rawStatus": NSNull(), "status": "unknown"],
|
|
74
|
+
] as [String: Any],
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static func correlate(_ observation: [String: Any], requestId: String) throws -> [String: Any] {
|
|
80
|
+
guard requestId.range(of: "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}\\z", options: .regularExpression) != nil else {
|
|
81
|
+
throw NSError(domain: "MentraPermissionStatus", code: 1,
|
|
82
|
+
userInfo: [NSLocalizedDescriptionKey: "One bounded permission request ID is required"])
|
|
83
|
+
}
|
|
84
|
+
return observation.merging(["requestId": requestId]) { _, fresh in fresh }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static func write(_ receipt: [String: Any], to path: URL) throws {
|
|
88
|
+
let files = FileManager.default
|
|
89
|
+
try files.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true,
|
|
90
|
+
attributes: [.posixPermissions: 0o700])
|
|
91
|
+
try JSONSerialization.data(withJSONObject: receipt, options: [.sortedKeys]).write(to: path, options: .atomic)
|
|
92
|
+
try files.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static func publish(requestId: String) async throws -> [String: Any] {
|
|
96
|
+
// Validate before observing; the request never controls a filesystem path.
|
|
97
|
+
_ = try correlate([:], requestId: requestId)
|
|
98
|
+
let observation = await current()
|
|
99
|
+
let receipt = try correlate(observation, requestId: requestId)
|
|
100
|
+
let cache = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask,
|
|
101
|
+
appropriateFor: nil, create: true)
|
|
102
|
+
try write(receipt, to: cache.appendingPathComponent(relativePath))
|
|
103
|
+
return receipt
|
|
104
|
+
}
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/crust",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0-beta.942",
|
|
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.
|
|
38
|
+
"@mentra/jspolyfill": "3.3.0-beta.942"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@expo/config-plugins": ">=8.0.0",
|
package/plugin/build/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { type ConfigPlugin } from "expo/config-plugins";
|
|
|
14
14
|
* both fails the release build with duplicate classes)
|
|
15
15
|
* - core-library desugaring (crust's AAR metadata requires it of embedding
|
|
16
16
|
* apps — the Nav SDK uses newer core libs)
|
|
17
|
+
* - Kotlin 2.3.0 or newer, with the root Kotlin Gradle plugin pinned to it
|
|
18
|
+
* (crust's QuickJS library, quickjs-kt 1.0.15, is compiled with Kotlin 2.4)
|
|
17
19
|
* - a generated MainApplication process guard so the notification-listener
|
|
18
20
|
* process never initializes React Native
|
|
19
21
|
* - iOS Mapbox Swift Package products and the Crust build-order dependency
|
package/plugin/build/index.js
CHANGED
|
@@ -21,6 +21,8 @@ const withMapboxNavIos_1 = __importDefault(require("./withMapboxNavIos"));
|
|
|
21
21
|
* both fails the release build with duplicate classes)
|
|
22
22
|
* - core-library desugaring (crust's AAR metadata requires it of embedding
|
|
23
23
|
* apps — the Nav SDK uses newer core libs)
|
|
24
|
+
* - Kotlin 2.3.0 or newer, with the root Kotlin Gradle plugin pinned to it
|
|
25
|
+
* (crust's QuickJS library, quickjs-kt 1.0.15, is compiled with Kotlin 2.4)
|
|
24
26
|
* - a generated MainApplication process guard so the notification-listener
|
|
25
27
|
* process never initializes React Native
|
|
26
28
|
* - iOS Mapbox Swift Package products and the Crust build-order dependency
|
|
@@ -20,12 +20,50 @@ const MAPBOX_REPO = [
|
|
|
20
20
|
" }",
|
|
21
21
|
" }",
|
|
22
22
|
].join("\n");
|
|
23
|
+
// quickjs-kt 1.0.15 is compiled with Kotlin 2.4, and a Kotlin compiler reads
|
|
24
|
+
// metadata one version ahead. 2.3.0 is the newest 2.3 release Expo SDK 55's
|
|
25
|
+
// root plugin maps to a KSP version.
|
|
26
|
+
const MIN_KOTLIN_VERSION = "2.3.0";
|
|
27
|
+
// The template's unversioned classpath entry resolves to the Kotlin Gradle
|
|
28
|
+
// plugin React Native's Gradle plugin depends on (2.1.20 on RN 0.83), whatever
|
|
29
|
+
// `android.kotlinVersion` says; Expo fixes that only in SDK 58
|
|
30
|
+
// (expo/expo#49668). SDK 56 also never copies the property into Expo's catalog,
|
|
31
|
+
// so pin both the compiler and the root `kotlinVersion` to the property itself.
|
|
32
|
+
const UNVERSIONED_KOTLIN_PLUGIN = "classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')";
|
|
33
|
+
const PINNED_KOTLIN_PLUGIN = `classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:\${findProperty('android.kotlinVersion')}")`;
|
|
34
|
+
const KOTLIN_VERSION_MARKER = "kotlinVersion from android.kotlinVersion (injected by @mentra/crust)";
|
|
35
|
+
const EXPO_ROOT_PROJECT = 'apply plugin: "expo-root-project"';
|
|
36
|
+
function isOlderVersion(version, minimum) {
|
|
37
|
+
const actual = version.split(".").map(Number);
|
|
38
|
+
const required = minimum.split(".").map(Number);
|
|
39
|
+
for (let i = 0; i < required.length; i++) {
|
|
40
|
+
if ((actual[i] ?? 0) !== required[i])
|
|
41
|
+
return (actual[i] ?? 0) < required[i];
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
function withCrustKotlinVersion(config) {
|
|
46
|
+
return (0, config_plugins_1.withGradleProperties)(config, (cfg) => {
|
|
47
|
+
const property = cfg.modResults.find((item) => item.type === "property" && item.key === "android.kotlinVersion");
|
|
48
|
+
if (property?.type !== "property") {
|
|
49
|
+
cfg.modResults.push({ type: "property", key: "android.kotlinVersion", value: MIN_KOTLIN_VERSION });
|
|
50
|
+
}
|
|
51
|
+
else if (isOlderVersion(property.value, MIN_KOTLIN_VERSION)) {
|
|
52
|
+
property.value = MIN_KOTLIN_VERSION;
|
|
53
|
+
}
|
|
54
|
+
return cfg;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
23
57
|
const PROTOBUF_EXCLUDE = "exclude group: 'com.google.protobuf', module: 'protobuf-javalite'";
|
|
24
58
|
const NOTIFICATION_PROCESS_GUARD = "crust: skip React Native in notification process";
|
|
25
59
|
const NOTIFICATION_CONFIG_GUARD = "crust: skip Expo lifecycle in notification process";
|
|
26
60
|
function withCrustProjectGradle(config) {
|
|
27
61
|
return (0, config_plugins_1.withProjectBuildGradle)(config, (cfg) => {
|
|
28
62
|
let gradle = cfg.modResults.contents;
|
|
63
|
+
gradle = gradle.replace(UNVERSIONED_KOTLIN_PLUGIN, PINNED_KOTLIN_PLUGIN);
|
|
64
|
+
if (!gradle.includes(KOTLIN_VERSION_MARKER)) {
|
|
65
|
+
gradle = gradle.replace(EXPO_ROOT_PROJECT, `// ${KOTLIN_VERSION_MARKER}\next.kotlinVersion = findProperty('android.kotlinVersion')\n${EXPO_ROOT_PROJECT}`);
|
|
66
|
+
}
|
|
29
67
|
// Mapbox Downloads repo, inserted at the top of allprojects.repositories.
|
|
30
68
|
if (!gradle.includes(MAPBOX_REPO_MARKER)) {
|
|
31
69
|
const reposMatch = gradle.match(/allprojects\s*\{[\s\S]*?repositories\s*\{/);
|
|
@@ -129,6 +167,7 @@ function withNotificationProcessGuard(config) {
|
|
|
129
167
|
});
|
|
130
168
|
}
|
|
131
169
|
const withCrustAndroidBuildContract = (config) => {
|
|
170
|
+
config = withCrustKotlinVersion(config);
|
|
132
171
|
config = withCrustProjectGradle(config);
|
|
133
172
|
config = withCrustAppGradle(config);
|
|
134
173
|
return withNotificationProcessGuard(config);
|
package/src/Crust.types.ts
CHANGED
|
@@ -16,7 +16,6 @@ export type CrustModuleEvents = {
|
|
|
16
16
|
onHeading: (params: HeadingPayload) => void
|
|
17
17
|
phone_notification: (event: PhoneNotificationEvent) => void
|
|
18
18
|
phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void
|
|
19
|
-
captions_tester_incident: (event: CaptionsTesterIncidentEvent) => void
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export type NavOffRoutePayload = {
|
|
@@ -114,6 +113,8 @@ export type NavManeuverPayload = {
|
|
|
114
113
|
|
|
115
114
|
export type NavErrorPayload = {
|
|
116
115
|
message: string
|
|
116
|
+
/** True only when native navigation has ended; absent for recoverable reroute errors. */
|
|
117
|
+
terminal?: boolean
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
export type InstalledApp = {
|
|
@@ -144,19 +145,32 @@ export type PhoneNotificationDismissedEvent = {
|
|
|
144
145
|
notificationId: string
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
export type CaptionsTesterIncidentEvent = {
|
|
148
|
-
action?: string
|
|
149
|
-
timestamp?: number
|
|
150
|
-
failure_code?: string
|
|
151
|
-
failure_message?: string
|
|
152
|
-
test_run_id?: string
|
|
153
|
-
scenario_name?: string
|
|
154
|
-
source?: string
|
|
155
|
-
[key: string]: unknown
|
|
156
|
-
}
|
|
157
|
-
|
|
158
148
|
export type CrustViewProps = {
|
|
159
149
|
url: string
|
|
160
150
|
onLoad: (event: {nativeEvent: OnLoadEventPayload}) => void
|
|
161
151
|
style?: StyleProp<ViewStyle>
|
|
162
152
|
}
|
|
153
|
+
|
|
154
|
+
/** Passive OS observations for the currently executing Mentra App. */
|
|
155
|
+
export type AppPermissionStatus = {
|
|
156
|
+
rawStatus: string | null
|
|
157
|
+
status: "granted" | "denied" | "blocked" | "unavailable" | "unknown"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type AppPermissionSnapshot = {
|
|
161
|
+
schemaVersion: 1
|
|
162
|
+
observedAt: string
|
|
163
|
+
identity: {
|
|
164
|
+
pid: number
|
|
165
|
+
uid: number
|
|
166
|
+
bundleId: string
|
|
167
|
+
bundlePath: string
|
|
168
|
+
executablePath: string
|
|
169
|
+
version: string
|
|
170
|
+
build: string
|
|
171
|
+
}
|
|
172
|
+
locationServicesEnabled: boolean
|
|
173
|
+
permissions: Record<"location" | "camera" | "microphone" | "bluetooth" | "localNetwork", AppPermissionStatus>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type AppPermissionReceipt = AppPermissionSnapshot & {requestId: string}
|
package/src/CrustModule.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import {NativeModule, requireNativeModule} from "expo"
|
|
2
2
|
|
|
3
|
-
import {CrustModuleEvents, InstalledApp} from "./Crust.types"
|
|
3
|
+
import {AppPermissionReceipt, AppPermissionSnapshot, CrustModuleEvents, InstalledApp} from "./Crust.types"
|
|
4
4
|
|
|
5
5
|
declare class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
6
|
+
/** True for the native iOS app running on an Apple Silicon Mac. */
|
|
7
|
+
readonly isIOSAppOnMac?: boolean
|
|
8
|
+
/** iOS: passive actual-app authorization status; never requests consent. */
|
|
9
|
+
getPermissionStatus(requestId: null): Promise<AppPermissionSnapshot>
|
|
10
|
+
/** iOS: a nonce publishes fresh status at the fixed managed-cache path. */
|
|
11
|
+
getPermissionStatus(requestId: string): Promise<AppPermissionReceipt>
|
|
6
12
|
PI: number
|
|
7
13
|
hello(): string
|
|
8
14
|
setValueAsync(value: string): Promise<void>
|
|
15
|
+
/** Android: copy the displayed view's window pixels to a temporary JPEG URI. */
|
|
16
|
+
captureMiniappPreview(viewTag: number): Promise<string>
|
|
9
17
|
nativeHttpRequest(
|
|
18
|
+
requestId: string,
|
|
10
19
|
method: string,
|
|
11
20
|
url: string,
|
|
12
21
|
headers: Record<string, string>,
|
|
13
22
|
body?: string | null,
|
|
14
23
|
): Promise<{status: number; statusText: string; headers: Record<string, string>; body: string}>
|
|
24
|
+
cancelNativeHttpRequest(requestId: string): Promise<void>
|
|
15
25
|
showAVRoutePicker(tintColor?: string | null): void
|
|
16
26
|
|
|
17
27
|
/**
|
package/src/CrustModule.web.ts
CHANGED
|
@@ -4,18 +4,29 @@ import {CrustModuleEvents} from "./Crust.types"
|
|
|
4
4
|
|
|
5
5
|
class CrustModule extends NativeModule<CrustModuleEvents> {
|
|
6
6
|
PI = Math.PI
|
|
7
|
+
private readonly httpRequests = new Map<string, AbortController>()
|
|
7
8
|
async setValueAsync(value: string): Promise<void> {
|
|
8
9
|
this.emit("onChange", {value})
|
|
9
10
|
}
|
|
10
|
-
async nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
async nativeHttpRequest(requestId: string, method: string, url: string, headers: Record<string, string>, body?: string | null) {
|
|
12
|
+
const controller = new AbortController()
|
|
13
|
+
this.httpRequests.set(requestId, controller)
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(url, {method, headers, body, signal: controller.signal})
|
|
16
|
+
return {
|
|
17
|
+
status: response.status,
|
|
18
|
+
statusText: response.statusText,
|
|
19
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
20
|
+
body: await response.text(),
|
|
21
|
+
}
|
|
22
|
+
} finally {
|
|
23
|
+
this.httpRequests.delete(requestId)
|
|
17
24
|
}
|
|
18
25
|
}
|
|
26
|
+
async cancelNativeHttpRequest(requestId: string): Promise<void> {
|
|
27
|
+
this.httpRequests.get(requestId)?.abort()
|
|
28
|
+
this.httpRequests.delete(requestId)
|
|
29
|
+
}
|
|
19
30
|
hello() {
|
|
20
31
|
return "Hello world! 👋"
|
|
21
32
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {nativeHttpRequest, type NativeHttpResult, type NativeHttpTransport} from "./nativeHttpRequest"
|
|
2
|
+
|
|
3
|
+
const response: NativeHttpResult = {status: 200, statusText: "OK", headers: {}, body: "{}"}
|
|
4
|
+
|
|
5
|
+
function deferred<T>() {
|
|
6
|
+
let resolve!: (value: T) => void
|
|
7
|
+
let reject!: (error: unknown) => void
|
|
8
|
+
const promise = new Promise<T>((yes, no) => {
|
|
9
|
+
resolve = yes
|
|
10
|
+
reject = no
|
|
11
|
+
})
|
|
12
|
+
return {promise, resolve, reject}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function transport(request = deferred<NativeHttpResult>()) {
|
|
16
|
+
const native: NativeHttpTransport = {
|
|
17
|
+
nativeHttpRequest: jest.fn(() => request.promise),
|
|
18
|
+
cancelNativeHttpRequest: jest.fn(async () => {}),
|
|
19
|
+
}
|
|
20
|
+
return {native, request}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("nativeHttpRequest", () => {
|
|
24
|
+
test("pre-aborted requests do not start or cancel native work", async () => {
|
|
25
|
+
const {native} = transport()
|
|
26
|
+
const controller = new AbortController()
|
|
27
|
+
const reason = new Error("deadline")
|
|
28
|
+
controller.abort(reason)
|
|
29
|
+
await expect(nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal)).rejects.toBe(reason)
|
|
30
|
+
expect(native.nativeHttpRequest).not.toHaveBeenCalled()
|
|
31
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("abort cancels only this request and waits for native cancellation acceptance", async () => {
|
|
35
|
+
const {native, request} = transport()
|
|
36
|
+
const cancellation = deferred<void>()
|
|
37
|
+
native.cancelNativeHttpRequest = jest.fn(() => cancellation.promise)
|
|
38
|
+
const controller = new AbortController()
|
|
39
|
+
const remove = jest.spyOn(controller.signal, "removeEventListener")
|
|
40
|
+
const promise = nativeHttpRequest(native, "POST", "https://example.com", {test: "header"}, "body", controller.signal)
|
|
41
|
+
const rejected = expect(promise).rejects.toMatchObject({name: "AbortError"})
|
|
42
|
+
controller.abort()
|
|
43
|
+
const id = (native.nativeHttpRequest as jest.Mock).mock.calls[0][0]
|
|
44
|
+
expect(native.cancelNativeHttpRequest).toHaveBeenCalledWith(id)
|
|
45
|
+
expect(remove).not.toHaveBeenCalled()
|
|
46
|
+
request.reject(new Error("cancelled socket"))
|
|
47
|
+
cancellation.resolve()
|
|
48
|
+
await rejected
|
|
49
|
+
expect(native.cancelNativeHttpRequest).toHaveBeenCalledTimes(1)
|
|
50
|
+
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("an abort during native start is cancelled after start and removes its listener", async () => {
|
|
54
|
+
const {native} = transport()
|
|
55
|
+
const controller = new AbortController()
|
|
56
|
+
const order: string[] = []
|
|
57
|
+
native.nativeHttpRequest = jest.fn(() => {
|
|
58
|
+
order.push("start")
|
|
59
|
+
controller.abort()
|
|
60
|
+
return new Promise<NativeHttpResult>(() => {})
|
|
61
|
+
})
|
|
62
|
+
native.cancelNativeHttpRequest = jest.fn(async () => {order.push("cancel")})
|
|
63
|
+
await expect(nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal)).rejects.toMatchObject({name: "AbortError"})
|
|
64
|
+
expect(order).toEqual(["start", "cancel"])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test.each(["success", "failure"])("%s removes its abort listener and a later abort does not cancel", async (outcome) => {
|
|
68
|
+
const {native, request} = transport()
|
|
69
|
+
const controller = new AbortController()
|
|
70
|
+
const remove = jest.spyOn(controller.signal, "removeEventListener")
|
|
71
|
+
const promise = nativeHttpRequest(native, "GET", "https://example.com", {}, null, controller.signal)
|
|
72
|
+
if (outcome === "success") {
|
|
73
|
+
request.resolve(response)
|
|
74
|
+
await expect(promise).resolves.toBe(response)
|
|
75
|
+
} else {
|
|
76
|
+
request.reject(new Error("offline"))
|
|
77
|
+
await expect(promise).rejects.toThrow("offline")
|
|
78
|
+
}
|
|
79
|
+
controller.abort()
|
|
80
|
+
expect(remove).toHaveBeenCalledTimes(1)
|
|
81
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("independent calls have separate IDs and unsignalled calls keep the native path", async () => {
|
|
85
|
+
const {native, request} = transport()
|
|
86
|
+
const first = nativeHttpRequest(native, "GET", "https://example.com/first", {}, null)
|
|
87
|
+
const second = nativeHttpRequest(native, "GET", "https://example.com/second", {}, null)
|
|
88
|
+
const calls = (native.nativeHttpRequest as jest.Mock).mock.calls
|
|
89
|
+
expect(calls[0][0]).not.toBe(calls[1][0])
|
|
90
|
+
request.resolve(response)
|
|
91
|
+
await Promise.all([first, second])
|
|
92
|
+
expect(native.cancelNativeHttpRequest).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
})
|