@mentra/engine 3.2.0-dev.116 → 3.2.0-dev.121

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 (84) hide show
  1. package/build/engine.d.ts.map +1 -1
  2. package/build/engine.js +7 -2
  3. package/build/engine.js.map +1 -1
  4. package/build/generated/releaseMetadata.js +5 -5
  5. package/build/generated/releaseMetadata.js.map +1 -1
  6. package/build/index.d.ts +1 -0
  7. package/build/index.d.ts.map +1 -1
  8. package/build/index.js +1 -0
  9. package/build/index.js.map +1 -1
  10. package/build/runtime/streamConfig.d.ts +7 -0
  11. package/build/runtime/streamConfig.d.ts.map +1 -1
  12. package/build/runtime/streamConfig.js +7 -0
  13. package/build/runtime/streamConfig.js.map +1 -1
  14. package/build/services/AcsMeetingService.d.ts +154 -0
  15. package/build/services/AcsMeetingService.d.ts.map +1 -0
  16. package/build/services/AcsMeetingService.js +477 -0
  17. package/build/services/AcsMeetingService.js.map +1 -0
  18. package/build/services/AppRegistry.d.ts +9 -0
  19. package/build/services/AppRegistry.d.ts.map +1 -1
  20. package/build/services/AppRegistry.js +22 -0
  21. package/build/services/AppRegistry.js.map +1 -1
  22. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  23. package/build/services/DeviceEventRouter.js +12 -3
  24. package/build/services/DeviceEventRouter.js.map +1 -1
  25. package/build/services/GlassesSettingsSync.d.ts.map +1 -1
  26. package/build/services/GlassesSettingsSync.js +11 -1
  27. package/build/services/GlassesSettingsSync.js.map +1 -1
  28. package/build/services/GlassesStatusProjection.d.ts +12 -0
  29. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  30. package/build/services/GlassesStatusProjection.js +23 -0
  31. package/build/services/GlassesStatusProjection.js.map +1 -1
  32. package/build/services/LocalMiniappRuntime.d.ts +12 -0
  33. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  34. package/build/services/LocalMiniappRuntime.js +184 -2
  35. package/build/services/LocalMiniappRuntime.js.map +1 -1
  36. package/build/services/MentraJSRouter.d.ts.map +1 -1
  37. package/build/services/MentraJSRouter.js +8 -1
  38. package/build/services/MentraJSRouter.js.map +1 -1
  39. package/build/services/MiniappLauncher.d.ts +3 -1
  40. package/build/services/MiniappLauncher.d.ts.map +1 -1
  41. package/build/services/MiniappLauncher.js +66 -50
  42. package/build/services/MiniappLauncher.js.map +1 -1
  43. package/build/services/PhoneCameraFovCoordinator.d.ts +18 -0
  44. package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -1
  45. package/build/services/PhoneCameraFovCoordinator.js +36 -0
  46. package/build/services/PhoneCameraFovCoordinator.js.map +1 -1
  47. package/build/services/PhoneStreamCoordinator.d.ts +67 -1
  48. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  49. package/build/services/PhoneStreamCoordinator.js +204 -15
  50. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  51. package/build/services/StreamLifecycleController.d.ts +23 -5
  52. package/build/services/StreamLifecycleController.d.ts.map +1 -1
  53. package/build/services/StreamLifecycleController.js +31 -10
  54. package/build/services/StreamLifecycleController.js.map +1 -1
  55. package/build/services/acsAudioSource.d.ts +24 -0
  56. package/build/services/acsAudioSource.d.ts.map +1 -0
  57. package/build/services/acsAudioSource.js +37 -0
  58. package/build/services/acsAudioSource.js.map +1 -0
  59. package/build/utils/devMiniappLaunch.d.ts.map +1 -1
  60. package/build/utils/devMiniappLaunch.js +4 -0
  61. package/build/utils/devMiniappLaunch.js.map +1 -1
  62. package/build/utils/devMiniappSnapshot.d.ts +46 -0
  63. package/build/utils/devMiniappSnapshot.d.ts.map +1 -0
  64. package/build/utils/devMiniappSnapshot.js +94 -0
  65. package/build/utils/devMiniappSnapshot.js.map +1 -0
  66. package/package.json +7 -6
  67. package/src/engine.ts +10 -2
  68. package/src/generated/releaseMetadata.ts +5 -5
  69. package/src/index.ts +4 -0
  70. package/src/runtime/streamConfig.ts +13 -0
  71. package/src/services/AcsMeetingService.ts +570 -0
  72. package/src/services/AppRegistry.ts +23 -0
  73. package/src/services/DeviceEventRouter.ts +12 -3
  74. package/src/services/GlassesSettingsSync.ts +13 -3
  75. package/src/services/GlassesStatusProjection.ts +28 -0
  76. package/src/services/LocalMiniappRuntime.ts +214 -2
  77. package/src/services/MentraJSRouter.ts +7 -1
  78. package/src/services/MiniappLauncher.ts +73 -50
  79. package/src/services/PhoneCameraFovCoordinator.ts +34 -0
  80. package/src/services/PhoneStreamCoordinator.ts +232 -19
  81. package/src/services/StreamLifecycleController.ts +42 -12
  82. package/src/services/acsAudioSource.ts +53 -0
  83. package/src/utils/devMiniappLaunch.ts +4 -0
  84. package/src/utils/devMiniappSnapshot.ts +119 -0
@@ -14,6 +14,34 @@
14
14
  import BluetoothSdk, {type PublicGlassesStatus} from "@mentra/bluetooth-sdk"
15
15
  import {useCoreStore} from "../stores/core"
16
16
  import {useGlassesStore} from "../stores/glasses"
17
+ import {isGlassesConnected} from "./GlassesReadiness"
18
+
19
+ /** Miniapp `session.glasses.onConnection` payload. */
20
+ export type MiniappConnectionData = {
21
+ connected: boolean
22
+ modelName?: string
23
+ }
24
+
25
+ /**
26
+ * Native `glasses_status` is a store delta (`connection.state`, battery, wifi…).
27
+ * Miniapps subscribe to a boolean `connected` field. Forwarding the raw status
28
+ * makes `Boolean(data.connected)` false on every heartbeat while the Mentra App
29
+ * still shows the glasses linked.
30
+ */
31
+ export function toMiniappConnectionData(status: unknown): MiniappConnectionData | null {
32
+ if (!status || typeof status !== "object") return null
33
+ const rec = status as Partial<PublicGlassesStatus> & {connected?: boolean; modelName?: string}
34
+ if (typeof rec.connected === "boolean") {
35
+ const modelName = rec.modelName || rec.deviceModel
36
+ return {connected: rec.connected, ...(modelName ? {modelName} : {})}
37
+ }
38
+ if (!rec.connection) return null
39
+ const modelName = rec.deviceModel
40
+ return {
41
+ connected: isGlassesConnected(rec.connection),
42
+ ...(modelName ? {modelName} : {}),
43
+ }
44
+ }
17
45
 
18
46
  let unsubs: Array<() => void> = []
19
47
  let projectionRunId = 0
@@ -34,6 +34,7 @@ import {BgTimer} from "../utils/timers"
34
34
  import devServerBridge from "./DevServerBridge"
35
35
  import {islandNotifications} from "./NotificationsEmitter"
36
36
  import {isGlassesConnected} from "./GlassesReadiness"
37
+ import {toMiniappConnectionData} from "./GlassesStatusProjection"
37
38
  import audioPlaybackService from "./AudioPlaybackService"
38
39
  import {phoneLocationService} from "./PhoneLocationService"
39
40
  import {useGlassesStore} from "../stores/glasses"
@@ -67,7 +68,7 @@ import {
67
68
  } from "../runtime/config"
68
69
  import {getAnalytics, getUiSeams} from "../runtime/bootstrap"
69
70
  import {invokeScanQrSeam} from "../runtime/scanQrSeam"
70
- import {normalizeStreamAudioConfig, normalizeStreamVideoConfig} from "../runtime/streamConfig"
71
+ import {normalizeStreamAudioConfig, normalizeStreamVideoConfig, resolveCaptureAudio} from "../runtime/streamConfig"
71
72
  import {toLanguageHint} from "@mentra/cloud-protocol/languages"
72
73
  import type {AudioSubscription, LanguageSource, TranscriptionData, TranslationData} from "@mentra/cloud-protocol"
73
74
  import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
@@ -80,6 +81,7 @@ import {resolveForegroundLocationPermission} from "./ForegroundLocationPermissio
80
81
  import {advanceMiniappPingLiveness} from "./MiniappLiveness"
81
82
  import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
82
83
  import {LocalMiniappStorage} from "./LocalMiniappStorage"
84
+ import acsMeetingService, {parseAcsOutgoingVideo, resolveAcsAudioSource} from "./AcsMeetingService"
83
85
 
84
86
  // =============================================================================
85
87
  // Types
@@ -758,6 +760,22 @@ class LocalMiniappRuntime {
758
760
  this.replaceStreamSubscribers(packageName, existing.subscriptions, [])
759
761
  this.recomputeMicRequirements()
760
762
  this.updateCloudSubscriptions()
763
+ // Managed streams and ACS meetings deliberately survive a respawn: the new
764
+ // incarnation re-adopts them on restore (Mentra Call does exactly this) so a
765
+ // WebView reload does not drop the wearer out of a live call. An unmanaged
766
+ // stream cannot be re-adopted — startUnmanaged rejects while any stream is
767
+ // active, even for the same owner — so release it or the respawned miniapp
768
+ // is stuck behind STREAM_ALREADY_ACTIVE until the process restarts.
769
+ const streamSnapshot = phoneStreamCoordinator.getDiagnosticSnapshot()
770
+ if (
771
+ streamSnapshot.active === true &&
772
+ streamSnapshot.kind === "unmanaged" &&
773
+ streamSnapshot.ownerPackageName === packageName
774
+ ) {
775
+ void phoneStreamCoordinator.stop(packageName).catch((error) => {
776
+ console.warn(`${LOG_TAG}: failed to stop unmanaged stream while replacing ${packageName}`, error)
777
+ })
778
+ }
761
779
  }
762
780
  this.connectedApps.set(packageName, {
763
781
  subscriptions: new Set(),
@@ -953,6 +971,9 @@ class LocalMiniappRuntime {
953
971
  void phoneVideoCoordinator.stopForApp(packageName).catch((error) => {
954
972
  console.warn(`${LOG_TAG}: failed to stop video recording for ${packageName} on unregister`, error)
955
973
  })
974
+ void acsMeetingService.leaveIfOwner(packageName).catch((error) => {
975
+ console.warn(`${LOG_TAG}: failed to leave ACS meeting for ${packageName} on unregister`, error)
976
+ })
956
977
 
957
978
  // Detach the per-app nav event forwarder but leave the native nav session
958
979
  // running. The user may have just closed the mini-app UI and will reopen
@@ -1307,6 +1328,21 @@ class LocalMiniappRuntime {
1307
1328
  case MiniappRequestType.MANAGED_STREAM_STOP:
1308
1329
  void this.handleManagedStreamStop(packageName, payload, requestId)
1309
1330
  break
1331
+ case MiniappRequestType.MEETING_JOIN:
1332
+ void this.handleMeetingJoin(packageName, payload, requestId)
1333
+ break
1334
+ case MiniappRequestType.MEETING_LEAVE:
1335
+ void this.handleMeetingLeave(packageName, requestId)
1336
+ break
1337
+ case MiniappRequestType.MEETING_SET_MUTED:
1338
+ void this.handleMeetingSetMuted(packageName, payload, requestId)
1339
+ break
1340
+ case MiniappRequestType.MEETING_UPDATE_VIDEO_SOURCE:
1341
+ void this.handleMeetingUpdateVideoSource(packageName, payload, requestId)
1342
+ break
1343
+ case MiniappRequestType.MEETING_GET_STATE:
1344
+ void this.handleMeetingGetState(packageName, requestId)
1345
+ break
1310
1346
  case REQUEST_WIFI_SETUP_TYPE:
1311
1347
  void this.handleRequestWifiSetup(packageName, payload, requestId)
1312
1348
  break
@@ -1409,6 +1445,7 @@ class LocalMiniappRuntime {
1409
1445
  packageName,
1410
1446
  capabilities,
1411
1447
  permissions: declaredPermissions,
1448
+ hostFeatures: {captureAudio: true},
1412
1449
  ...(initialAuth ? {auth: initialAuth} : {}),
1413
1450
  },
1414
1451
  requestId,
@@ -1797,7 +1834,10 @@ class LocalMiniappRuntime {
1797
1834
  this.sendToMiniapp(packageName, {
1798
1835
  type: MiniappResponseType.EVENT,
1799
1836
  streamType: "glasses_connection",
1800
- data: glassesState,
1837
+ data: {
1838
+ connected: glassesState.connected,
1839
+ modelName: glassesState.deviceModel || undefined,
1840
+ },
1801
1841
  })
1802
1842
  } else if (stream === "glasses_wifi") {
1803
1843
  // Snapshot the current glasses Wi-Fi state on subscribe (like battery).
@@ -1816,6 +1856,7 @@ class LocalMiniappRuntime {
1816
1856
  streamType: "glasses_wifi",
1817
1857
  data: {
1818
1858
  connected,
1859
+ linkConnected: glassesState.connected === true,
1819
1860
  ssid: connected ? wifi?.ssid : undefined,
1820
1861
  localIp: connected ? wifi?.localIp : undefined,
1821
1862
  timestamp: Date.now(),
@@ -3241,6 +3282,30 @@ class LocalMiniappRuntime {
3241
3282
  // Photo + streaming handlers (cloud-coordinated)
3242
3283
  // ===========================================================================
3243
3284
 
3285
+ /**
3286
+ * Manifest permission gate shared by the camera-and-mic RPCs (streams, meetings).
3287
+ * Sends PERMISSION_NOT_DECLARED and returns false when the miniapp did not declare
3288
+ * `permission`; the caller must return without doing the work.
3289
+ */
3290
+ private requireManifestPermission(
3291
+ packageName: string,
3292
+ requestId: string | undefined,
3293
+ permission: "CAMERA" | "MICROPHONE",
3294
+ purpose: string,
3295
+ operation: MiniappRequestType,
3296
+ ): boolean {
3297
+ const app = this.connectedApps.get(packageName)
3298
+ if (app?.installedManifest?.permissions?.some((p) => p.type === permission)) return true
3299
+ logPermissionNotDeclared(packageName, permission, purpose, `{"type": "${permission}"}`)
3300
+ this.sendResult(packageName, requestId, false, undefined, {
3301
+ code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
3302
+ message: `${permission} permission not declared in miniapp.json. Add {"type": "${permission}"} to the "permissions" array.`,
3303
+ permission,
3304
+ operation,
3305
+ })
3306
+ return false
3307
+ }
3308
+
3244
3309
  private async handlePhoto(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
3245
3310
  // Manifest CAMERA permission gate.
3246
3311
  const app = this.connectedApps.get(packageName)
@@ -3399,6 +3464,10 @@ class LocalMiniappRuntime {
3399
3464
  })
3400
3465
  return
3401
3466
  }
3467
+ // A stream is the glasses camera (and mic) leaving the device; gate it like a photo.
3468
+ if (!this.requireManifestPermission(packageName, requestId, "CAMERA", "to stream the camera", MiniappRequestType.STREAM_START)) {
3469
+ return
3470
+ }
3402
3471
  try {
3403
3472
  const result = await streaming.startUnmanaged(packageName, {
3404
3473
  streamUrl: payload.streamUrl as string,
@@ -3406,6 +3475,7 @@ class LocalMiniappRuntime {
3406
3475
  audio: normalizeStreamAudioConfig(payload.audio),
3407
3476
  sound: payload.sound as boolean | undefined,
3408
3477
  authToken: typeof payload.authToken === "string" ? payload.authToken : undefined,
3478
+ captureAudio: resolveCaptureAudio(payload.captureAudio, resolveAcsAudioSource().source),
3409
3479
  })
3410
3480
  this.sendResult(packageName, requestId, true, result)
3411
3481
  } catch (err) {
@@ -3455,6 +3525,17 @@ class LocalMiniappRuntime {
3455
3525
  })
3456
3526
  return
3457
3527
  }
3528
+ if (
3529
+ !this.requireManifestPermission(
3530
+ packageName,
3531
+ requestId,
3532
+ "CAMERA",
3533
+ "to stream the camera",
3534
+ MiniappRequestType.MANAGED_STREAM_START,
3535
+ )
3536
+ ) {
3537
+ return
3538
+ }
3458
3539
  try {
3459
3540
  const result = await streaming.startManaged(packageName, {
3460
3541
  restreamDestinations: payload.restreamDestinations as Array<string | {url: string; name?: string}> | undefined,
@@ -3462,6 +3543,7 @@ class LocalMiniappRuntime {
3462
3543
  audio: normalizeStreamAudioConfig(payload.audio),
3463
3544
  sound: payload.sound as boolean | undefined,
3464
3545
  ingest: payload.ingest as "srt" | "whip" | undefined,
3546
+ captureAudio: resolveCaptureAudio(payload.captureAudio, resolveAcsAudioSource().source),
3465
3547
  })
3466
3548
  this.sendResult(packageName, requestId, true, result)
3467
3549
  } catch (err) {
@@ -3482,6 +3564,133 @@ class LocalMiniappRuntime {
3482
3564
  return this.handleStreamStop(packageName, payload, requestId)
3483
3565
  }
3484
3566
 
3567
+ private ensureMeetingStateBridge(): void {
3568
+ acsMeetingService.setStateHandler((owner, state) => {
3569
+ this.sendToMiniapp(owner, {
3570
+ type: MiniappResponseType.MEETING_STATE,
3571
+ ...state,
3572
+ })
3573
+ })
3574
+ }
3575
+
3576
+ private async handleMeetingJoin(
3577
+ packageName: string,
3578
+ payload: Record<string, unknown>,
3579
+ requestId?: string,
3580
+ ): Promise<void> {
3581
+ this.ensureMeetingStateBridge()
3582
+ // A meeting puts the glasses camera and mic in front of remote strangers; it
3583
+ // needs both declared, same as photo/stream and mic capture do individually.
3584
+ if (
3585
+ !this.requireManifestPermission(packageName, requestId, "CAMERA", "to join a meeting", MiniappRequestType.MEETING_JOIN) ||
3586
+ !this.requireManifestPermission(packageName, requestId, "MICROPHONE", "to join a meeting", MiniappRequestType.MEETING_JOIN)
3587
+ ) {
3588
+ return
3589
+ }
3590
+ const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : ""
3591
+ const token = typeof payload.token === "string" ? payload.token : ""
3592
+ const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
3593
+ const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : ""
3594
+ const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined
3595
+ if (!meetingUrl || !token || !whepUrl) {
3596
+ this.sendResult(packageName, requestId, false, undefined, {
3597
+ code: MiniappErrorCode.INVALID_ARGUMENT,
3598
+ message: "meetingUrl, token, and a WHEP videoSource are required",
3599
+ })
3600
+ return
3601
+ }
3602
+ try {
3603
+ let video: ReturnType<typeof parseAcsOutgoingVideo>
3604
+ try {
3605
+ video = parseAcsOutgoingVideo(payload.video)
3606
+ } catch (error) {
3607
+ this.sendResult(packageName, requestId, false, undefined, {
3608
+ code: MiniappErrorCode.INVALID_ARGUMENT,
3609
+ message: error instanceof Error ? error.message : "Invalid meeting video",
3610
+ })
3611
+ return
3612
+ }
3613
+ const state = await acsMeetingService.join(packageName, {
3614
+ meetingUrl,
3615
+ token,
3616
+ whepUrl,
3617
+ displayName,
3618
+ ...(video ? {video} : {}),
3619
+ })
3620
+ this.sendResult(packageName, requestId, true, state)
3621
+ } catch (err) {
3622
+ this.sendResult(packageName, requestId, false, undefined, {
3623
+ code: MiniappErrorCode.INTERNAL,
3624
+ message: err instanceof Error ? err.message : "ACS meeting join failed",
3625
+ })
3626
+ }
3627
+ }
3628
+
3629
+ private async handleMeetingLeave(packageName: string, requestId?: string): Promise<void> {
3630
+ try {
3631
+ await acsMeetingService.leave(packageName)
3632
+ this.sendResult(packageName, requestId, true)
3633
+ } catch (err) {
3634
+ this.sendResult(packageName, requestId, false, undefined, {
3635
+ code: MiniappErrorCode.INTERNAL,
3636
+ message: err instanceof Error ? err.message : "ACS meeting leave failed",
3637
+ })
3638
+ }
3639
+ }
3640
+
3641
+ private async handleMeetingSetMuted(
3642
+ packageName: string,
3643
+ payload: Record<string, unknown>,
3644
+ requestId?: string,
3645
+ ): Promise<void> {
3646
+ try {
3647
+ const state = await acsMeetingService.setMuted(packageName, Boolean(payload.muted))
3648
+ this.sendResult(packageName, requestId, true, state)
3649
+ } catch (err) {
3650
+ this.sendResult(packageName, requestId, false, undefined, {
3651
+ code: MiniappErrorCode.INTERNAL,
3652
+ message: err instanceof Error ? err.message : "ACS mute failed",
3653
+ })
3654
+ }
3655
+ }
3656
+
3657
+ private async handleMeetingUpdateVideoSource(
3658
+ packageName: string,
3659
+ payload: Record<string, unknown>,
3660
+ requestId?: string,
3661
+ ): Promise<void> {
3662
+ const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
3663
+ const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : ""
3664
+ if (!whepUrl) {
3665
+ this.sendResult(packageName, requestId, false, undefined, {
3666
+ code: MiniappErrorCode.INVALID_ARGUMENT,
3667
+ message: "videoSource must be a WHEP URL",
3668
+ })
3669
+ return
3670
+ }
3671
+ try {
3672
+ await acsMeetingService.updateVideoSource(packageName, whepUrl)
3673
+ this.sendResult(packageName, requestId, true)
3674
+ } catch (err) {
3675
+ this.sendResult(packageName, requestId, false, undefined, {
3676
+ code: MiniappErrorCode.INTERNAL,
3677
+ message: err instanceof Error ? err.message : "ACS video source update failed",
3678
+ })
3679
+ }
3680
+ }
3681
+
3682
+ private async handleMeetingGetState(packageName: string, requestId?: string): Promise<void> {
3683
+ try {
3684
+ const state = await acsMeetingService.readState(packageName)
3685
+ this.sendResult(packageName, requestId, true, state)
3686
+ } catch (err) {
3687
+ this.sendResult(packageName, requestId, false, undefined, {
3688
+ code: MiniappErrorCode.INTERNAL,
3689
+ message: err instanceof Error ? err.message : "ACS getState failed",
3690
+ })
3691
+ }
3692
+ }
3693
+
3485
3694
  /**
3486
3695
  * session.glasses.requestWifiSetup — open the phone's glasses Wi-Fi setup
3487
3696
  * flow. The host owns the actual UI via the `engine.configure({ui})` seam;
@@ -3904,6 +4113,9 @@ class LocalMiniappRuntime {
3904
4113
  // The bare `touch_event` stream above still catches `onTouch(handler)`.
3905
4114
  let perGestureStream: string | null = null
3906
4115
  let outboundData = data
4116
+ if (normalizedStream === MiniappStreamType.GLASSES_CONNECTION) {
4117
+ outboundData = toMiniappConnectionData(data) ?? data
4118
+ }
3907
4119
  if (normalizedStream === MiniappStreamType.TOUCH_EVENT) {
3908
4120
  // The Bluetooth SDK delivers the gesture under `gestureName` (single_tap /
3909
4121
  // double_tap / triple_tap / long_press / swipe_up / swipe_down). Surface it
@@ -469,7 +469,13 @@ export class MentraJSRouter {
469
469
  // dead and let the host surface a "tap to retry" banner.
470
470
  if (iface === "__error") {
471
471
  const payload = this.tryParseArgs(msg.argsJson)
472
- this.logger.error(`[${packageName}] ${method}`, payload)
472
+ // ready_nack is a liveness probe, not a crash. Logging it at error
473
+ // painted WHIP-start queue delay as a Mentra-Call exception.
474
+ if (method === "ready_nack") {
475
+ this.logger.warn(`[${packageName}] ${method}`, payload)
476
+ } else {
477
+ this.logger.error(`[${packageName}] ${method}`, payload)
478
+ }
473
479
  if (this.crashController) {
474
480
  // Only treat "exception" + "unhandledRejection" + "uncaught" as
475
481
  // crash signals. `console.error` calls also flow through the
@@ -25,7 +25,7 @@
25
25
 
26
26
  import {File} from "expo-file-system"
27
27
 
28
- import {decideDevLaunchRoute} from "../utils/devMiniappLaunch"
28
+ import {resolveDevBundleSource} from "../utils/devMiniappSnapshot"
29
29
  import {storage} from "../utils/storage/storage"
30
30
  import appRegistry, {getLocalAppRunningState, saveLocalAppRunningState} from "./AppRegistry"
31
31
  import devServerBridge from "./DevServerBridge"
@@ -96,67 +96,89 @@ class MiniappLauncher {
96
96
  * package. Handles both dev (HTTP off the running dev server) and released
97
97
  * (file:// from the installed snapshot). Reads disk/network/storage; does
98
98
  * NOT spawn. Returns null when the bundle can't be resolved (dev server
99
- * unreachable, missing entry, no installed version).
99
+ * unreachable with no on-disk snapshot, missing entry, no installed version).
100
100
  */
101
101
  async resolveBundle(packageName: string, hints?: LaunchHints): Promise<ResolvedBundle | null> {
102
102
  const devUrl = hints?.devUrl ?? this.storedDevUrl(packageName)
103
103
 
104
- // --- Dev: load directly off the local dev server over HTTP. ---
104
+ // --- Dev: live HTTP, then the last on-disk snapshot if the laptop is gone. ---
105
105
  if (devUrl) {
106
- const route = await decideDevLaunchRoute(packageName, devUrl)
107
- if (route.decision === "offline" || !route.manifest) return null
108
- const manifest = route.manifest
109
- const entry = manifest.entry as {background?: string; ui?: string} | undefined
110
- if (!entry?.background) return null
111
-
112
- // Use the host that actually answered — may differ from the stored IP
113
- // after a laptop Wi-Fi change (mDNS / Metro failover inside decideDevLaunchRoute).
114
- const base = route.resolvedUrl.replace(/\/$/, "")
115
- // entry.* are bundle-root paths (dist/ stripped); the dev server serves
116
- // files relative to cwd, so prepend dist/.
117
- const bgUrl = `${base}/dist/${entry.background.replace(/^\.?\/+/, "")}`
118
- const uiUri = entry.ui ? `${base}/dist/${entry.ui.replace(/^\.?\/+/, "")}` : null
119
-
120
- const perms = manifest.permissions as Array<{type?: string} | string> | undefined
121
- const declaredPermissions = (perms ?? [])
122
- .map((p) => (typeof p === "string" ? p : p?.type))
123
- .filter((t): t is string => typeof t === "string")
124
- const installedManifest: InstalledMiniappManifest = {
125
- packageName: typeof manifest.packageName === "string" ? manifest.packageName : packageName,
126
- name: manifest.name,
127
- version: typeof manifest.version === "string" ? manifest.version : undefined,
128
- sdkVersion: typeof manifest.sdkVersion === "string" ? manifest.sdkVersion : undefined,
129
- minHostVersion: typeof manifest.minHostVersion === "string" ? manifest.minHostVersion : undefined,
130
- type: typeof manifest.type === "string" ? manifest.type : undefined,
131
- entry: manifest.entry as InstalledMiniappManifest["entry"],
132
- permissions: manifest.permissions as InstalledMiniappManifest["permissions"],
133
- hardwareRequirements: manifest.hardwareRequirements as InstalledMiniappManifest["hardwareRequirements"],
134
- actions: manifest.actions as InstalledMiniappManifest["actions"],
106
+ const source = await resolveDevBundleSource(packageName, devUrl)
107
+ if (source.kind === "live") {
108
+ const live = await this.resolveLiveHttp(packageName, source.resolvedUrl, source.manifest, hints)
109
+ if (live) return live
110
+ // Live probe succeeded but the entry fetch failed — still try disk.
135
111
  }
136
-
137
- let bgSource: string
138
- try {
139
- const res = await fetch(bgUrl)
140
- if (!res.ok) return null
141
- bgSource = await res.text()
142
- } catch {
143
- return null
144
- }
145
-
146
- return {
147
- bgSource,
148
- uiUri,
149
- uiBaseDir: uiUri ? uiUri.replace(/\/[^/]+$/, "/") : null,
150
- declaredPermissions,
151
- installedManifest,
152
- devUrl: route.resolvedUrl,
153
- devPort: this.resolveDevPort(hints?.devPort, packageName),
112
+ const snapshotVersion =
113
+ source.kind === "snapshot" ? source.version : appRegistry.getLatestDevSnapshotVersion(packageName)
114
+ if (snapshotVersion) {
115
+ const snapshot = await this.resolveInstalledBundle(packageName, snapshotVersion)
116
+ if (snapshot) return snapshot
154
117
  }
118
+ return null
155
119
  }
156
120
 
157
121
  // --- Released: resolve from the installed file:// snapshot. ---
158
122
  const version = hints?.version ?? (await appRegistry.getActiveVersion(packageName))
159
123
  if (!version) return null
124
+ return this.resolveInstalledBundle(packageName, version)
125
+ }
126
+
127
+ private async resolveLiveHttp(
128
+ packageName: string,
129
+ resolvedUrl: string,
130
+ manifest: {entry?: unknown; permissions?: unknown; [key: string]: unknown},
131
+ hints?: LaunchHints,
132
+ ): Promise<ResolvedBundle | null> {
133
+ const entry = manifest.entry as {background?: string; ui?: string} | undefined
134
+ if (!entry?.background) return null
135
+
136
+ // Use the host that actually answered — may differ from the stored IP
137
+ // after a laptop Wi-Fi change (mDNS / Metro failover inside decideDevLaunchRoute).
138
+ const base = resolvedUrl.replace(/\/$/, "")
139
+ // entry.* are bundle-root paths (dist/ stripped); the dev server serves
140
+ // files relative to cwd, so prepend dist/.
141
+ const bgUrl = `${base}/dist/${entry.background.replace(/^\.?\/+/, "")}`
142
+ const uiUri = entry.ui ? `${base}/dist/${entry.ui.replace(/^\.?\/+/, "")}` : null
143
+
144
+ const perms = manifest.permissions as Array<{type?: string} | string> | undefined
145
+ const declaredPermissions = (perms ?? [])
146
+ .map((p) => (typeof p === "string" ? p : p?.type))
147
+ .filter((t): t is string => typeof t === "string")
148
+ const installedManifest: InstalledMiniappManifest = {
149
+ packageName: typeof manifest.packageName === "string" ? manifest.packageName : packageName,
150
+ name: typeof manifest.name === "string" ? manifest.name : undefined,
151
+ version: typeof manifest.version === "string" ? manifest.version : undefined,
152
+ sdkVersion: typeof manifest.sdkVersion === "string" ? manifest.sdkVersion : undefined,
153
+ minHostVersion: typeof manifest.minHostVersion === "string" ? manifest.minHostVersion : undefined,
154
+ type: typeof manifest.type === "string" ? manifest.type : undefined,
155
+ entry: manifest.entry as InstalledMiniappManifest["entry"],
156
+ permissions: manifest.permissions as InstalledMiniappManifest["permissions"],
157
+ hardwareRequirements: manifest.hardwareRequirements as InstalledMiniappManifest["hardwareRequirements"],
158
+ actions: manifest.actions as InstalledMiniappManifest["actions"],
159
+ }
160
+
161
+ let bgSource: string
162
+ try {
163
+ const res = await fetch(bgUrl)
164
+ if (!res.ok) return null
165
+ bgSource = await res.text()
166
+ } catch {
167
+ return null
168
+ }
169
+
170
+ return {
171
+ bgSource,
172
+ uiUri,
173
+ uiBaseDir: uiUri ? uiUri.replace(/\/[^/]+$/, "/") : null,
174
+ declaredPermissions,
175
+ installedManifest,
176
+ devUrl: resolvedUrl,
177
+ devPort: this.resolveDevPort(hints?.devPort, packageName),
178
+ }
179
+ }
180
+
181
+ private async resolveInstalledBundle(packageName: string, version: string): Promise<ResolvedBundle | null> {
160
182
  const entryPaths = appRegistry.getMiniappEntryPaths(packageName, version)
161
183
  if (!entryPaths?.background) return null
162
184
 
@@ -203,6 +225,7 @@ class MiniappLauncher {
203
225
  uiBaseDir: entryPaths.ui ? entryPaths.ui.replace(/\/[^/]+$/, "/") : null,
204
226
  declaredPermissions,
205
227
  installedManifest,
228
+ // Snapshot fallback is file:// — do not wire the sidecar; the laptop is gone.
206
229
  devUrl: null,
207
230
  devPort: null,
208
231
  }
@@ -90,6 +90,40 @@ export class PhoneCameraFovCoordinator {
90
90
  })
91
91
  }
92
92
 
93
+ /**
94
+ * Re-assert the current override on the glasses.
95
+ *
96
+ * The on-connect device-settings push replays the persistent `camera_fov`,
97
+ * so glasses that connect mid-call come up at the user's saved value and
98
+ * silently drop a miniapp's override — a Mentra Call wearer loses the 102°
99
+ * bottom crop by connecting their glasses after joining.
100
+ *
101
+ * The effective lease is recomputed inside [enqueue], not captured at call
102
+ * time: a `setOverride` or `releaseForApp` already queued ahead of this must
103
+ * win, and it is this coordinator that has to be the authoritative last
104
+ * writer. Sends the complete override — `fov` *and* `roiPosition` — because
105
+ * the scalar alone leaves the ROI wherever the persistent setting put it.
106
+ *
107
+ * No-op in legacy mode: those commands restart the camera HAL, which is far
108
+ * too expensive to fire on every reconnect.
109
+ */
110
+ reapplyEffectiveOverride(): Promise<void> {
111
+ return this.enqueue(async () => {
112
+ if (this.legacyMode) return
113
+ const leaseId = this.effectiveLeaseId
114
+ if (!leaseId) return
115
+ const entry = [...this.overrides.values()].find((candidate) => candidate.leaseId === leaseId)
116
+ if (!entry) return
117
+ await BluetoothSdk.setCameraFovOverride({
118
+ leaseId,
119
+ fov: entry.fov,
120
+ roiPosition: entry.roiPosition,
121
+ ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,
122
+ })
123
+ this.scheduleRefresh()
124
+ })
125
+ }
126
+
93
127
  releaseForApp(packageName: string): Promise<void> {
94
128
  return this.enqueue(async () => {
95
129
  const removed = this.overrides.get(packageName)