@mentra/crust 3.2.1-dev.314 → 3.3.0-beta.1000

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +27 -0
  2. package/android/build.gradle +27 -9
  3. package/android/src/main/AndroidManifest.xml +7 -0
  4. package/android/src/main/java/com/mentra/crust/CrustModule.kt +76 -22
  5. package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
  6. package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
  7. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +70 -60
  8. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
  9. package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
  10. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +74 -10
  11. package/android/src/main/java/com/mentra/crust/navigation/NavigationSessionGuard.kt +74 -0
  12. package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
  13. package/android/src/main/java/com/mentra/crust/services/RuntimeRecoveryService.kt +12 -0
  14. package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
  15. package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
  16. package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +199 -0
  17. package/android/src/test/java/com/mentra/crust/jsc/HttpRequestTest.kt +32 -0
  18. package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
  19. package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
  20. package/android/src/test/java/com/mentra/crust/navigation/NavigationSessionGuardTest.kt +68 -0
  21. package/build/Crust.types.d.ts +25 -11
  22. package/build/Crust.types.d.ts.map +1 -1
  23. package/build/Crust.types.js.map +1 -1
  24. package/build/CrustModule.d.ts +11 -2
  25. package/build/CrustModule.d.ts.map +1 -1
  26. package/build/CrustModule.js.map +1 -1
  27. package/build/CrustModule.web.d.ts +3 -1
  28. package/build/CrustModule.web.d.ts.map +1 -1
  29. package/build/CrustModule.web.js +20 -8
  30. package/build/CrustModule.web.js.map +1 -1
  31. package/build/index.d.ts +1 -0
  32. package/build/index.d.ts.map +1 -1
  33. package/build/index.js +1 -0
  34. package/build/index.js.map +1 -1
  35. package/build/nativeHttpRequest.d.ts +13 -0
  36. package/build/nativeHttpRequest.d.ts.map +1 -0
  37. package/build/nativeHttpRequest.js +51 -0
  38. package/build/nativeHttpRequest.js.map +1 -0
  39. package/build/nativeHttpRequest.test.d.ts +2 -0
  40. package/build/nativeHttpRequest.test.d.ts.map +1 -0
  41. package/build/nativeHttpRequest.test.js +88 -0
  42. package/build/nativeHttpRequest.test.js.map +1 -0
  43. package/ios/Crust.podspec +1 -1
  44. package/ios/CrustModule.swift +22 -7
  45. package/ios/PermissionStatusReceipt.swift +105 -0
  46. package/ios/Resources/startup.js +154 -68
  47. package/ios/Source/JSCPolyfillBridge.swift +31 -14
  48. package/package.json +2 -2
  49. package/plugin/build/index.d.ts +2 -0
  50. package/plugin/build/index.js +2 -0
  51. package/plugin/build/withAndroid.js +39 -0
  52. package/src/Crust.types.ts +26 -12
  53. package/src/CrustModule.ts +11 -1
  54. package/src/CrustModule.web.ts +18 -7
  55. package/src/index.ts +1 -0
  56. package/src/nativeHttpRequest.test.ts +94 -0
  57. package/src/nativeHttpRequest.ts +74 -0
  58. package/android/src/internal/AndroidManifest.xml +0 -9
  59. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +0 -46
@@ -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
+ }
@@ -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
- 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;
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 bodyText = typeof result.body === "string" ? result.body : "";
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 bodyText;
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(bodyText);
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(bodyText).buffer;
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 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) {
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 bytesToBase64 = bytesToBase642, base64ToBytes = base64ToBytes2, queueMicrotaskSafe = queueMicrotaskSafe2;
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
- const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
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 = base64ToBytes2(ev.data).buffer;
841
+ synth.data = base64ToBytes(ev.data).buffer;
756
842
  } else if (type === "message" && ev.kind === "text") {
757
843
  synth.data = ev.data;
758
844
  }
@@ -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.2.1-dev.314",
3
+ "version": "3.3.0-beta.1000",
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.2.1-dev.314"
38
+ "@mentra/jspolyfill": "3.3.0-beta.1000"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@expo/config-plugins": ">=8.0.0",
@@ -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
@@ -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);
@@ -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}
@@ -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
  /**