@mentra/engine 3.2.0-dev.259 → 3.2.0-dev.262

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 (73) hide show
  1. package/build/facades/displayMirror.js +1 -1
  2. package/build/facades/displayMirror.js.map +1 -1
  3. package/build/generated/releaseMetadata.js +5 -5
  4. package/build/generated/releaseMetadata.js.map +1 -1
  5. package/build/services/AcsMeetingService.d.ts +25 -1
  6. package/build/services/AcsMeetingService.d.ts.map +1 -1
  7. package/build/services/AcsMeetingService.js +41 -3
  8. package/build/services/AcsMeetingService.js.map +1 -1
  9. package/build/services/DisplayProcessor.d.ts +1 -1
  10. package/build/services/DisplayProcessor.js.map +1 -1
  11. package/build/services/LocalDisplayManager.d.ts +0 -4
  12. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  13. package/build/services/LocalDisplayManager.js +2 -7
  14. package/build/services/LocalDisplayManager.js.map +1 -1
  15. package/build/services/LocalMiniappRuntime.d.ts +39 -24
  16. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  17. package/build/services/LocalMiniappRuntime.js +293 -123
  18. package/build/services/LocalMiniappRuntime.js.map +1 -1
  19. package/build/services/MentraJSRouter.d.ts +5 -9
  20. package/build/services/MentraJSRouter.d.ts.map +1 -1
  21. package/build/services/MentraJSRouter.js +5 -9
  22. package/build/services/MentraJSRouter.js.map +1 -1
  23. package/build/services/MentraUIRouter.d.ts +9 -12
  24. package/build/services/MentraUIRouter.d.ts.map +1 -1
  25. package/build/services/MentraUIRouter.js +9 -12
  26. package/build/services/MentraUIRouter.js.map +1 -1
  27. package/build/services/MiniappLauncher.d.ts +6 -9
  28. package/build/services/MiniappLauncher.d.ts.map +1 -1
  29. package/build/services/MiniappLauncher.js +6 -9
  30. package/build/services/MiniappLauncher.js.map +1 -1
  31. package/build/services/MiniappLiveness.d.ts +11 -0
  32. package/build/services/MiniappLiveness.d.ts.map +1 -1
  33. package/build/services/MiniappLiveness.js +11 -0
  34. package/build/services/MiniappLiveness.js.map +1 -1
  35. package/build/services/PhoneLocationService.d.ts +2 -2
  36. package/build/services/PhoneLocationService.d.ts.map +1 -1
  37. package/build/services/PhoneLocationService.js +5 -9
  38. package/build/services/PhoneLocationService.js.map +1 -1
  39. package/build/services/PhoneStreamCoordinator.d.ts +14 -3
  40. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  41. package/build/services/PhoneStreamCoordinator.js +28 -6
  42. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  43. package/build/services/SoftapCallTransport.d.ts +87 -7
  44. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  45. package/build/services/SoftapCallTransport.js +229 -28
  46. package/build/services/SoftapCallTransport.js.map +1 -1
  47. package/build/services/slimStreamStatus.d.ts +4 -1
  48. package/build/services/slimStreamStatus.d.ts.map +1 -1
  49. package/build/services/slimStreamStatus.js +4 -1
  50. package/build/services/slimStreamStatus.js.map +1 -1
  51. package/build/stores/apps.js +2 -2
  52. package/build/stores/apps.js.map +1 -1
  53. package/build/utils/miniappGlobals.d.ts +3 -5
  54. package/build/utils/miniappGlobals.d.ts.map +1 -1
  55. package/build/utils/miniappGlobals.js +16 -16
  56. package/build/utils/miniappGlobals.js.map +1 -1
  57. package/package.json +8 -8
  58. package/src/facades/displayMirror.ts +1 -1
  59. package/src/generated/releaseMetadata.ts +5 -5
  60. package/src/services/AcsMeetingService.ts +51 -3
  61. package/src/services/DisplayProcessor.ts +1 -1
  62. package/src/services/LocalDisplayManager.ts +2 -7
  63. package/src/services/LocalMiniappRuntime.ts +305 -133
  64. package/src/services/MentraJSRouter.ts +5 -9
  65. package/src/services/MentraUIRouter.ts +9 -12
  66. package/src/services/MiniappLauncher.ts +6 -9
  67. package/src/services/MiniappLiveness.ts +18 -0
  68. package/src/services/PhoneLocationService.ts +5 -9
  69. package/src/services/PhoneStreamCoordinator.ts +28 -7
  70. package/src/services/SoftapCallTransport.ts +274 -32
  71. package/src/services/slimStreamStatus.ts +4 -1
  72. package/src/stores/apps.ts +2 -2
  73. package/src/utils/miniappGlobals.ts +16 -16
@@ -35,7 +35,7 @@ import {storage as mmkvStorage} from "../utils/storage/storage"
35
35
  import {BgTimer} from "../utils/timers"
36
36
  import devServerBridge from "./DevServerBridge"
37
37
  import {islandNotifications} from "./NotificationsEmitter"
38
- import {isGlassesConnected} from "./GlassesReadiness"
38
+ import {isGlassesConnected, isGlassesReady, waitForGlassesReady} from "./GlassesReadiness"
39
39
  import {toMiniappConnectionData} from "./GlassesStatusProjection"
40
40
  import audioPlaybackService from "./AudioPlaybackService"
41
41
  import {phoneLocationService} from "./PhoneLocationService"
@@ -85,7 +85,7 @@ import type {ClientApp} from "../types/applet"
85
85
  import {useAppStatusStore} from "../stores/apps"
86
86
  import appRegistry, {getDevAppAttestation, getDevAppSourcePackage} from "./AppRegistry"
87
87
  import {resolveForegroundLocationPermission} from "./ForegroundLocationPermission"
88
- import {advanceMiniappPingLiveness} from "./MiniappLiveness"
88
+ import {advanceMiniappPingLiveness, shouldHoldMiniappPingLiveness} from "./MiniappLiveness"
89
89
  import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
90
90
  import {LocalMiniappStorage} from "./LocalMiniappStorage"
91
91
  import acsMeetingService, {
@@ -102,6 +102,7 @@ import {
102
102
  createSoftapCallDeps,
103
103
  SoftapCallError,
104
104
  SoftapCallTransport,
105
+ RETURN_DEADLINE_MS,
105
106
  SOFTAP_STEPS,
106
107
  type SoftapProgress,
107
108
  type SoftapTeardownMode,
@@ -237,6 +238,11 @@ const FOREGROUND_LIVENESS_PROBE_TIMEOUT_MS = 2_500
237
238
  * rather than sit in "connecting" while the wearer waits.
238
239
  */
239
240
  const SOFTAP_FIRST_FRAME_MS = 20_000
241
+ /**
242
+ * How long the BLE link must stay up before SoftAP recovery trusts it. Covers the gap between
243
+ * the hotspot dying and the phone noticing the link died with it.
244
+ */
245
+ const GLASSES_LINK_SETTLE_MS = 4_000
240
246
  const REQUEST_WIFI_SETUP_TYPE = "miniapp_request_wifi_setup"
241
247
  // Unregister after this many missed pongs. Generous on purpose: a busy
242
248
  // context (heavy interim translation traffic) or OS scheduling while idle can
@@ -414,6 +420,14 @@ type SoftapAttempt = {
414
420
  /** The checklist as the miniapp last saw it; preflight rows live here until `start()` takes over. */
415
421
  progress: SoftapProgress
416
422
  scopedLostUnsub: (() => void) | null
423
+ /**
424
+ * Mid-call media rebuild. Single-flight while [recoveryActive] is true; the promise is
425
+ * cleared when that run settles so a later walk-away can start another. Joined to the
426
+ * attempt barrier so a late re-arm cannot resurrect resources after Leave/End.
427
+ */
428
+ recovery: Promise<void> | null
429
+ recoveryActive: boolean
430
+ recoveryDeadlineAt: number | null
417
431
  /**
418
432
  * The join itself, settled either way. A native call still in flight owns state the teardown
419
433
  * cannot see — a `createCallAgent` mid-sign-in — so the barrier covers this as well.
@@ -444,6 +458,27 @@ const SOFTAP_HOTSPOT_OFF_ACK_MS = 5_000
444
458
  /** After a forced close there is nothing left to wait for, so this only catches a real failure. */
445
459
  const SOFTAP_FORCED_VERIFY_MS = 500
446
460
 
461
+ /**
462
+ * Rejection codes meaning the glasses replaced their Wi-Fi protocol session, not that the
463
+ * command failed.
464
+ *
465
+ * The glasses emit `wifi_protocol_session_ready` on every `glasses_ready`, which fires on BLE
466
+ * reconnects and ASG restarts — routine during a SoftAP teardown. The SDK cancels every pending
467
+ * Wi-Fi request when that lands, so a hotspot command in flight is lost even though nothing is
468
+ * actually wrong. `wifi_session_disconnected` is deliberately absent: that one is raised from the
469
+ * store update where the glasses went away, so there is nothing left to re-send to.
470
+ */
471
+ const HOTSPOT_SESSION_REFRESH_CODES = new Set(["wifi_session_restarted", "wifi_session_changed"])
472
+
473
+ /**
474
+ * Let the restarted ASG command path settle before re-sending.
475
+ *
476
+ * `handleSetHotspotState` returns false when its network manager is not up yet, which drops the
477
+ * command silently, so an immediate re-send would just race the same initialization. Mirrors the
478
+ * retry spacing `disableHotspotWithRetry` uses for the sibling ASG-replacement case.
479
+ */
480
+ const HOTSPOT_SESSION_RETRY_DELAY_MS = 500
481
+
447
482
  /** Reject with [reason] if [work] has not settled in [ms]. The work itself is not cancellable. */
448
483
  function withTimeout<T>(work: Promise<T>, ms: number, reason: string): Promise<T> {
449
484
  return new Promise<T>((resolve, reject) => {
@@ -553,8 +588,6 @@ class LocalMiniappRuntime {
553
588
  */
554
589
  public onLivenessTimeout: ((packageName: string) => void) | null = null
555
590
 
556
- /** Pending cloud requests: requestId → packageName that originated the request. */
557
- private pendingCloudRequests: Map<string, {packageName: string; envelopeRequestId?: string}> = new Map()
558
591
  private speechRuns = new Map<string, SpeechRun>()
559
592
 
560
593
  // Browser fallback token auth — HMAC-signed blob with a phone-local
@@ -759,90 +792,6 @@ class LocalMiniappRuntime {
759
792
  this.ensurePingLoop()
760
793
  }
761
794
 
762
- /**
763
- * Handle an incoming cloud message forwarded by SocketComms
764
- * (phone_stream_status, phone_managed_stream_status).
765
- *
766
- * Routes the response back to the originating miniapp via the requestId
767
- * that was stored when the miniapp first made the request.
768
- */
769
- public handleCloudMessage(msg: any): void {
770
- const requestId = msg.requestId as string | undefined
771
- const msgType = msg.type as string
772
-
773
- console.log(`${LOG_TAG}: Cloud message: ${msgType}, requestId=${requestId ?? "none"}`)
774
-
775
- if (!requestId) {
776
- console.warn(`${LOG_TAG}: Cloud message ${msgType} has no requestId, cannot route`)
777
- return
778
- }
779
-
780
- const pending = this.pendingCloudRequests.get(requestId)
781
- if (!pending) {
782
- console.warn(`${LOG_TAG}: No pending request for requestId=${requestId}`)
783
- return
784
- }
785
-
786
- this.pendingCloudRequests.delete(requestId)
787
-
788
- switch (msgType) {
789
- case "phone_stream_status": {
790
- // Unreachable for phone-orchestrated streams (the coordinator owns
791
- // their lifecycle and never registers a pending cloud request).
792
- // Retained as a safety net for any legacy registration path.
793
- this.sendToMiniapp(pending.packageName, {
794
- type: MiniappResponseType.EVENT,
795
- streamType: "stream_status",
796
- data: {
797
- streamId: msg.streamId,
798
- status: msg.status,
799
- errorDetails: msg.errorDetails,
800
- },
801
- })
802
- // Re-register for ongoing status updates (streams send multiple status messages)
803
- this.pendingCloudRequests.set(requestId, pending)
804
- break
805
- }
806
-
807
- case "phone_managed_stream_status": {
808
- if (msg.status === "connected" || msg.status === "active") {
809
- // Managed stream is ready — send back the playback URLs as the request result
810
- this.sendResult(pending.packageName, pending.envelopeRequestId, true, {
811
- streamId: msg.streamId,
812
- hlsUrl: msg.hlsUrl,
813
- dashUrl: msg.dashUrl,
814
- webrtcUrl: msg.webrtcUrl,
815
- })
816
- }
817
- // Forward all statuses as events too
818
- this.sendToMiniapp(pending.packageName, {
819
- type: MiniappResponseType.EVENT,
820
- streamType: "stream_status",
821
- data: {
822
- streamId: msg.streamId,
823
- status: msg.status,
824
- hlsUrl: msg.hlsUrl,
825
- dashUrl: msg.dashUrl,
826
- webrtcUrl: msg.webrtcUrl,
827
- },
828
- })
829
- // Re-register for ongoing updates
830
- this.pendingCloudRequests.set(requestId, pending)
831
- break
832
- }
833
-
834
- default:
835
- console.warn(`${LOG_TAG}: Unknown cloud message type: ${msgType}`)
836
- }
837
- }
838
-
839
- /**
840
- * Register a pending cloud request so we can route the response back.
841
- */
842
- public registerPendingCloudRequest(requestId: string, packageName: string, envelopeRequestId?: string): void {
843
- this.pendingCloudRequests.set(requestId, {packageName, envelopeRequestId})
844
- }
845
-
846
795
  // ===========================================================================
847
796
  // App registration
848
797
  // ===========================================================================
@@ -1113,13 +1062,6 @@ class LocalMiniappRuntime {
1113
1062
  app.requestedLocationRate = null
1114
1063
  this.recomputeLocationTier()
1115
1064
 
1116
- // Clean up any pending cloud requests from this app
1117
- for (const [reqId, pending] of this.pendingCloudRequests) {
1118
- if (pending.packageName === packageName) {
1119
- this.pendingCloudRequests.delete(reqId)
1120
- }
1121
- }
1122
-
1123
1065
  // Release any display real estate this app held — if it owned the
1124
1066
  // current on-glasses frame, this clears the glasses (or restores the
1125
1067
  // core app's saved frame).
@@ -3608,10 +3550,10 @@ class LocalMiniappRuntime {
3608
3550
  }
3609
3551
 
3610
3552
  /**
3611
- * Stream handlers — dispatched to the engine PhoneStreamCoordinator. For managed
3612
- * streams the coordinator additionally calls the v2 client REST route to
3613
- * provision Cloudflare. Cloud-SDK apps (third-party developers) use a
3614
- * separate cloud-side path that does not pass through here.
3553
+ * Stream handlers — dispatched to PhoneStreamCoordinator on the phone.
3554
+ * Managed streams also use Cloud V2 REST calls to provision resources,
3555
+ * poll ingest status, and tear down the managed stream. Miniapp JavaScript
3556
+ * runs locally in the Mentra App.
3615
3557
  */
3616
3558
  private async handleStreamStart(
3617
3559
  packageName: string,
@@ -3747,6 +3689,7 @@ class LocalMiniappRuntime {
3747
3689
  this.sendToMiniapp(owner, {
3748
3690
  type: MiniappResponseType.MEETING_STATE,
3749
3691
  ...state,
3692
+ ...this.softapRecoveryFields(attempt?.packageName === owner ? attempt : null),
3750
3693
  })
3751
3694
  if (attempt?.ownsResources && attempt.transport?.shouldRepublish(state.mediaSource)) {
3752
3695
  void attempt.transport.republish(state.mediaSourceReason ?? "mediaSource failed")
@@ -3958,8 +3901,12 @@ class LocalMiniappRuntime {
3958
3901
  phase: "starting",
3959
3902
  steps: SOFTAP_STEPS.map((step) => ({step, status: "pending" as const})),
3960
3903
  elapsedMs: 0,
3904
+ mediaGeneration: 0,
3961
3905
  },
3962
3906
  scopedLostUnsub: null,
3907
+ recovery: null,
3908
+ recoveryActive: false,
3909
+ recoveryDeadlineAt: null,
3963
3910
  body: null,
3964
3911
  teardown: null,
3965
3912
  settled,
@@ -4025,9 +3972,27 @@ class LocalMiniappRuntime {
4025
3972
  // join *is* in progress, and `idle` would make the miniapp think the call ended.
4026
3973
  state: current.state === "idle" ? "connecting" : current.state,
4027
3974
  softap: progress,
3975
+ ...this.softapRecoveryFields(attempt),
4028
3976
  })
4029
3977
  }
4030
3978
 
3979
+ private softapRecoveryFields(attempt: SoftapAttempt | null | undefined): {recovery?: {
3980
+ active: boolean
3981
+ generation?: number
3982
+ deadlineAt?: number
3983
+ phase?: string
3984
+ }} {
3985
+ if (!attempt?.recoveryDeadlineAt) return {}
3986
+ return {
3987
+ recovery: {
3988
+ active: attempt.recoveryActive,
3989
+ generation: attempt.transport?.currentMediaGeneration(),
3990
+ deadlineAt: attempt.recoveryDeadlineAt,
3991
+ phase: attempt.transport?.currentPhase(),
3992
+ },
3993
+ }
3994
+ }
3995
+
4031
3996
  /**
4032
3997
  * Narrates preflight work on the first row while it is still pending.
4033
3998
  *
@@ -4071,10 +4036,12 @@ class LocalMiniappRuntime {
4071
4036
  this.checkpointSoftapAttempt(attempt, "cleanup barrier")
4072
4037
  const cleanupError = this.softapCleanupError
4073
4038
  if (cleanupError) {
4074
- // Report once, allowing a retry after the wearer power-cycles the glasses.
4039
+ // Cleared on read, so this refuses at most once and the next attempt always gets through —
4040
+ // which is why the wearer is told to retry rather than to go power-cycle anything. The
4041
+ // native reason stays in the trace: it names a leaked port, not something they can act on.
4075
4042
  this.softapCleanupError = null
4076
4043
  softapTraceFailure("softap_join_refused", {packageName, reason: cleanupError})
4077
- throw new Error(`Previous call cleanup failed: ${cleanupError}. Power-cycle the glasses hotspot and try again.`)
4044
+ throw new Error("Previous call cleanup has not finished yet. Please try joining again.")
4078
4045
  }
4079
4046
  attempt.releaseHotspot = acquireGlassesHotspot()
4080
4047
  attempt.ownsResources = true
@@ -4104,7 +4071,8 @@ class LocalMiniappRuntime {
4104
4071
  token: args.token,
4105
4072
  displayName: args.displayName,
4106
4073
  video: args.video,
4107
- awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
4074
+ awaitFirstFrame: (_report, options) =>
4075
+ acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS, options),
4108
4076
  waitUntilLive: (timeoutMs) => acsMeetingService.waitUntilMediaLive(timeoutMs),
4109
4077
  subsystems: {
4110
4078
  setHotspotState: async (enabled) => {
@@ -4170,6 +4138,14 @@ class LocalMiniappRuntime {
4170
4138
  glassesLc3Uplink: () => acsMeetingService.glassesLc3UplinkActive(),
4171
4139
  startPublishing: (pkg, options) => phoneStreamCoordinator.startUnmanaged(pkg, options),
4172
4140
  stopPublishing: (pkg) => phoneStreamCoordinator.stop(pkg),
4141
+ discardPendingBleStop: () => phoneStreamCoordinator.discardPendingBleStop(),
4142
+ rebindIngest: async (report) => {
4143
+ report?.("Rebinding the glasses video receiver on the hotspot")
4144
+ const ingestUrl = await acsMeetingService.rebindSoftApIngest()
4145
+ if (!ingestUrl) throw new Error("the rebound meeting reported no ingest URL")
4146
+ report?.(`Receiver rebound at ${ingestUrl}`)
4147
+ return {ingestUrl}
4148
+ },
4173
4149
  },
4174
4150
  }),
4175
4151
  )
@@ -4202,14 +4178,7 @@ class LocalMiniappRuntime {
4202
4178
  reason: error.message,
4203
4179
  attemptAgeMs: Date.now() - attempt.startedAt,
4204
4180
  })
4205
- const current = acsMeetingService.getState()
4206
- this.sendToMiniapp(packageName, {
4207
- type: MiniappResponseType.MEETING_STATE,
4208
- ...current,
4209
- state: "error",
4210
- error: `SOFTAP_NETWORK_LOST: ${error.message}`,
4211
- softap: transport.progress(),
4212
- })
4181
+ this.beginSoftapRecovery(attempt, transport, error)
4213
4182
  })
4214
4183
 
4215
4184
  // Sign in on the phone's current internet *before* the glasses hotspot takes DNS. On device,
@@ -4249,6 +4218,163 @@ class LocalMiniappRuntime {
4249
4218
  return state
4250
4219
  }
4251
4220
 
4221
+ /**
4222
+ * SoftAP loss is a media outage. Keep ACS, destroy generation N, wait for the glasses,
4223
+ * then build generation N+1. Duplicate losses do not renew the deadline.
4224
+ */
4225
+ private beginSoftapRecovery(
4226
+ attempt: SoftapAttempt,
4227
+ transport: SoftapCallTransport,
4228
+ error: {code: string; message: string},
4229
+ ): void {
4230
+ // Gate on the live flag, not the promise: a settled Promise is still truthy, so a second
4231
+ // walk-away after a successful re-arm was dropped as "in-flight" and the glasses kept
4232
+ // POSTing WHIP at a hotspot the phone had already left.
4233
+ if (attempt.recoveryActive) {
4234
+ softapTrace("softap_recovery_dropped", {attempt: attempt.id, cause: "in-flight"})
4235
+ return
4236
+ }
4237
+ if (attempt.cancelled || this.softapAttempt !== attempt) {
4238
+ softapTrace("softap_recovery_dropped", {
4239
+ attempt: attempt.id,
4240
+ cause: attempt.cancelled ? "retired" : "superseded",
4241
+ })
4242
+ return
4243
+ }
4244
+ const lostAt = Date.now()
4245
+ attempt.recoveryDeadlineAt = lostAt + RETURN_DEADLINE_MS
4246
+ attempt.recoveryActive = true
4247
+ this.emitSoftapProgress(attempt, transport.progress())
4248
+ softapTrace("softap_recovery_begin", {
4249
+ attempt: attempt.id,
4250
+ deadlineAt: attempt.recoveryDeadlineAt,
4251
+ mediaGeneration: transport.currentMediaGeneration(),
4252
+ reason: error.message,
4253
+ })
4254
+
4255
+ let run!: Promise<void>
4256
+ run = (async () => {
4257
+ try {
4258
+ await transport.recover(`${error.code}: ${error.message}`, {
4259
+ wait: async () => {
4260
+ const remaining = (attempt.recoveryDeadlineAt ?? 0) - Date.now()
4261
+ if (remaining <= 0) {
4262
+ throw new SoftapCallError("hotspot", "RETURN_DEADLINE", "The glasses did not return in time")
4263
+ }
4264
+ const ready = await this.waitForGlassesReturn(remaining, () => attempt.cancelled)
4265
+ if (attempt.cancelled || this.softapAttempt !== attempt) {
4266
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled")
4267
+ }
4268
+ if (!ready) {
4269
+ throw new SoftapCallError("hotspot", "RETURN_DEADLINE", "The glasses did not return in time")
4270
+ }
4271
+ },
4272
+ })
4273
+ if (attempt.cancelled || this.softapAttempt !== attempt) return
4274
+ this.emitSoftapProgress(attempt, transport.progress())
4275
+ const current = acsMeetingService.getState()
4276
+ this.sendToMiniapp(attempt.packageName, {
4277
+ type: MiniappResponseType.MEETING_STATE,
4278
+ ...current,
4279
+ softap: transport.progress(),
4280
+ ...this.softapRecoveryFields(attempt),
4281
+ })
4282
+ softapTrace("softap_recovery_live", {
4283
+ attempt: attempt.id,
4284
+ mediaGeneration: transport.currentMediaGeneration(),
4285
+ elapsedMs: Date.now() - lostAt,
4286
+ })
4287
+ } catch (recoveryError) {
4288
+ if (attempt.cancelled || this.softapAttempt !== attempt) return
4289
+ const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
4290
+ softapTraceFailure("softap_recovery_failed", {
4291
+ attempt: attempt.id,
4292
+ reason: message,
4293
+ elapsedMs: Date.now() - lostAt,
4294
+ })
4295
+ const current = acsMeetingService.getState()
4296
+ this.sendToMiniapp(attempt.packageName, {
4297
+ type: MiniappResponseType.MEETING_STATE,
4298
+ ...current,
4299
+ state: "error",
4300
+ error: `SOFTAP_NETWORK_LOST: ${error.message}`,
4301
+ softap: transport.progress(),
4302
+ recovery: {
4303
+ active: false,
4304
+ generation: transport.currentMediaGeneration(),
4305
+ deadlineAt: attempt.recoveryDeadlineAt ?? undefined,
4306
+ phase: transport.currentPhase(),
4307
+ },
4308
+ })
4309
+ } finally {
4310
+ attempt.recoveryActive = false
4311
+ if (attempt.recovery === run) attempt.recovery = null
4312
+ }
4313
+ })()
4314
+ attempt.recovery = run
4315
+ }
4316
+
4317
+ /**
4318
+ * Wait for a link the hotspot commands can actually use.
4319
+ *
4320
+ * The hotspot goes away a couple of seconds before the phone declares the BLE link dead, so the
4321
+ * snapshot at loss time still reads "ready". Taking that at face value sent `set_hotspot_state`
4322
+ * into a dying link twice, and each one burned its full timeout before the wearer was even back.
4323
+ * Requiring the link to hold steady turns that into waiting, which is what the budget is for.
4324
+ */
4325
+ private async waitForGlassesReturn(timeoutMs: number, cancelled: () => boolean): Promise<boolean> {
4326
+ const deadlineAt = Date.now() + timeoutMs
4327
+ while (!cancelled()) {
4328
+ const remaining = deadlineAt - Date.now()
4329
+ if (remaining <= 0) return false
4330
+ if (!isGlassesReady(useGlassesStore.getState().connection) && !(await this.awaitGlassesReady(remaining, cancelled))) {
4331
+ return false
4332
+ }
4333
+ const settleMs = Math.min(GLASSES_LINK_SETTLE_MS, Math.max(0, deadlineAt - Date.now()))
4334
+ if (await this.glassesLinkHoldsSteady(settleMs, cancelled)) return true
4335
+ }
4336
+ return false
4337
+ }
4338
+
4339
+ private awaitGlassesReady(timeoutMs: number, cancelled: () => boolean): Promise<boolean> {
4340
+ const controller = new AbortController()
4341
+ const poll = setInterval(() => {
4342
+ if (cancelled()) controller.abort()
4343
+ }, 250)
4344
+ return waitForGlassesReady({
4345
+ getConnection: () => useGlassesStore.getState().connection,
4346
+ subscribe: (listener) => useGlassesStore.subscribe((state) => state.connection, listener),
4347
+ timeoutMs,
4348
+ signal: controller.signal,
4349
+ }).finally(() => clearInterval(poll))
4350
+ }
4351
+
4352
+ /** True when the link stays ready for [ms]; false the moment it drops again. */
4353
+ private glassesLinkHoldsSteady(ms: number, cancelled: () => boolean): Promise<boolean> {
4354
+ if (ms <= 0) return Promise.resolve(isGlassesReady(useGlassesStore.getState().connection))
4355
+ return new Promise<boolean>((resolve) => {
4356
+ let done = false
4357
+ const settle = (steady: boolean) => {
4358
+ if (done) return
4359
+ done = true
4360
+ clearTimeout(timer)
4361
+ clearInterval(poll)
4362
+ unsubscribe()
4363
+ resolve(steady)
4364
+ }
4365
+ const unsubscribe = useGlassesStore.subscribe(
4366
+ (state) => state.connection,
4367
+ (connection) => {
4368
+ if (!isGlassesReady(connection)) settle(false)
4369
+ },
4370
+ )
4371
+ const poll = setInterval(() => {
4372
+ if (cancelled()) settle(false)
4373
+ }, 250)
4374
+ const timer = setTimeout(() => settle(true), ms)
4375
+ })
4376
+ }
4377
+
4252
4378
  /**
4253
4379
  * Cancels a SoftAP attempt and does not return until everything it owns has been released.
4254
4380
  *
@@ -4291,7 +4417,11 @@ class LocalMiniappRuntime {
4291
4417
  afterMs: SOFTAP_CLEANUP_STALL_LOG_MS,
4292
4418
  })
4293
4419
  }, SOFTAP_CLEANUP_STALL_LOG_MS)
4294
- void Promise.allSettled([attempt.teardown, attempt.body ?? Promise.resolve()]).then(() => {
4420
+ void Promise.allSettled([
4421
+ attempt.teardown,
4422
+ attempt.body ?? Promise.resolve(),
4423
+ attempt.recovery ?? Promise.resolve(),
4424
+ ]).then(() => {
4295
4425
  clearTimeout(stallWatchdog)
4296
4426
  attempt.markSettled()
4297
4427
  if (this.softapAttempt === attempt) this.softapAttempt = null
@@ -4375,7 +4505,16 @@ class LocalMiniappRuntime {
4375
4505
  // so this is the caller's business, not a reason to block the next call.
4376
4506
  endFailure = await releaseStep("transport.stop", () => transport.stop(mode ? {mode} : {}))
4377
4507
  const undoFailures = transport.lastTeardownFailures()
4378
- if (undoFailures.length > 0) failures.push(`could not release ${undoFailures.join(", ")}`)
4508
+ // A hotspot that did not ACK off is leftover ON — the next join wants it on and will
4509
+ // call setHotspotState(true). Refusing that join is how we got hotspot_off_ack_timeout
4510
+ // as a wearer-facing "power-cycle the glasses" dead end.
4511
+ const blockingUndos = undoFailures.filter((step) => step !== "hotspot")
4512
+ if (blockingUndos.length > 0) failures.push(`could not release ${blockingUndos.join(", ")}`)
4513
+ if (undoFailures.includes("hotspot")) {
4514
+ console.warn("[LocalMiniappRuntime] glasses hotspot undo timed out; next join will raise it again", {
4515
+ attempt: attempt.id,
4516
+ })
4517
+ }
4379
4518
  }
4380
4519
  const scopedFailure = await releaseStep("leaveScopedNetwork", () => acsMeetingService.leaveScopedNetwork())
4381
4520
  if (scopedFailure) {
@@ -4477,11 +4616,30 @@ class LocalMiniappRuntime {
4477
4616
  if (endFailure) throw endFailure
4478
4617
  }
4479
4618
 
4619
+ /**
4620
+ * Send one hotspot command, re-sending it once if a session refresh cancelled it.
4621
+ *
4622
+ * The retry lives inside the unit chained onto [glassesHotspotCommand] rather than around it,
4623
+ * which is what holds the invariant: one logical hotspot operation issues at most two native
4624
+ * commands, and no later operation runs between them. A retry outside the queue would let an
4625
+ * `on` land between the two halves of an `off` and leave the glasses in the opposite state.
4626
+ *
4627
+ * Re-sending is free because ASG compares against current state first and answers a same-state
4628
+ * command with its current status instead of cycling the AP.
4629
+ */
4480
4630
  private setGlassesHotspotState(enabled: boolean) {
4481
- const run = this.glassesHotspotCommand.then(
4482
- () => BluetoothSdk.setHotspotState(enabled),
4483
- () => BluetoothSdk.setHotspotState(enabled),
4484
- )
4631
+ const send = async () => {
4632
+ try {
4633
+ return await BluetoothSdk.setHotspotState(enabled)
4634
+ } catch (error) {
4635
+ const code = (error as {code?: string} | null | undefined)?.code
4636
+ if (!code || !HOTSPOT_SESSION_REFRESH_CODES.has(code)) throw error
4637
+ softapTrace("softap_hotspot_session_retry", {enabled, code})
4638
+ await new Promise<void>((resolve) => setTimeout(resolve, HOTSPOT_SESSION_RETRY_DELAY_MS))
4639
+ return await BluetoothSdk.setHotspotState(enabled)
4640
+ }
4641
+ }
4642
+ const run = this.glassesHotspotCommand.then(send, send)
4485
4643
  this.glassesHotspotCommand = run.then(
4486
4644
  () => undefined,
4487
4645
  () => undefined,
@@ -4499,9 +4657,9 @@ class LocalMiniappRuntime {
4499
4657
  * failure — the listener was still inside its 3s tombstone and the glasses AP was still up.
4500
4658
  *
4501
4659
  * A bound that expires is not success. It means the resource is still held by a call nobody is
4502
- * in any more, so it is taken by force and re-verified. Only if *that* fails does the next join
4503
- * get refused, which is a worse outcome than a slow teardown and a better one than two calls
4504
- * sharing a port.
4660
+ * in any more, so it is taken by force and re-verified. An ingest port that is still held
4661
+ * refuses the next join. A glasses hotspot that missed its off-ack does not the next join
4662
+ * wants the AP up and will raise it again.
4505
4663
  *
4506
4664
  * @returns failures to record; a non-empty list refuses the next join.
4507
4665
  */
@@ -4594,9 +4752,17 @@ class LocalMiniappRuntime {
4594
4752
  SOFTAP_HOTSPOT_OFF_ACK_MS,
4595
4753
  "hotspot_off_ack_timeout",
4596
4754
  )
4597
- if (status.state !== "disabled") failures.push(`the glasses hotspot is still ${status.state}`)
4755
+ if (status.state !== "disabled") {
4756
+ console.warn("[LocalMiniappRuntime] glasses hotspot still reported after disable; next join will raise it", {
4757
+ attempt: attempt.id,
4758
+ state: status.state,
4759
+ })
4760
+ }
4598
4761
  } catch (error) {
4599
- failures.push(`glasses hotspot off: ${error instanceof Error ? error.message : String(error)}`)
4762
+ console.warn("[LocalMiniappRuntime] glasses hotspot off ack missed; next join will raise it", {
4763
+ attempt: attempt.id,
4764
+ reason: error instanceof Error ? error.message : String(error),
4765
+ })
4600
4766
  }
4601
4767
  }
4602
4768
  softapTrace("softap_teardown_forced_done", {
@@ -5128,11 +5294,11 @@ class LocalMiniappRuntime {
5128
5294
  * Forward a streamed event to all miniapps subscribed to the given stream.
5129
5295
  *
5130
5296
  * Event name translation:
5131
- * - Cloud sends "head_up" → miniapp protocol uses "head_position" (HEAD_POSITION)
5132
- * - Cloud sends "VAD" (uppercase) → miniapp protocol uses "vad" (lowercase)
5297
+ * - Incoming "head_up" → miniapp protocol uses "head_position" (HEAD_POSITION)
5298
+ * - Incoming "VAD" (uppercase) → miniapp protocol uses "vad" (lowercase)
5133
5299
  */
5134
5300
  public forwardEvent(streamType: string, data: unknown, transcriptionSource?: TranscriptionEventSource): void {
5135
- // Translate cloud event names to miniapp protocol stream types
5301
+ // Normalize incoming event names to miniapp protocol stream types
5136
5302
  const normalizedStream = this.normalizeStreamType(streamType)
5137
5303
 
5138
5304
  // Collect all subscribers: exact match, plus wildcard matches for streams
@@ -5291,10 +5457,10 @@ class LocalMiniappRuntime {
5291
5457
  }
5292
5458
 
5293
5459
  /**
5294
- * Translate cloud event names to miniapp stream type values.
5460
+ * Normalize incoming event names to miniapp stream type values.
5295
5461
  */
5296
5462
  private normalizeStreamType(cloudEventName: string): string {
5297
- // Cloud / Bluetooth SDK → miniapp protocol translations.
5463
+ // Incoming event names → miniapp protocol translations.
5298
5464
  // Bluetooth SDK event names don't always match the miniapp wire values.
5299
5465
  switch (cloudEventName) {
5300
5466
  case "head_up":
@@ -5859,13 +6025,20 @@ class LocalMiniappRuntime {
5859
6025
  const toRemove: string[] = []
5860
6026
 
5861
6027
  for (const [packageName, app] of this.connectedApps) {
5862
- const liveness = advanceMiniappPingLiveness(app.unansweredPingRounds, PING_TIMEOUT_THRESHOLD)
5863
- if (liveness.shouldUnregister) {
5864
- console.warn(`${LOG_TAG}: ${packageName} missed ${PING_TIMEOUT_THRESHOLD} pings, unregistering`)
5865
- toRemove.push(packageName)
5866
- continue
6028
+ const holdPingLiveness = shouldHoldMiniappPingLiveness({
6029
+ packageName,
6030
+ softapPackageName: this.softapAttempt?.packageName,
6031
+ softapCancelled: this.softapAttempt?.cancelled,
6032
+ })
6033
+ if (!holdPingLiveness) {
6034
+ const liveness = advanceMiniappPingLiveness(app.unansweredPingRounds, PING_TIMEOUT_THRESHOLD)
6035
+ if (liveness.shouldUnregister) {
6036
+ console.warn(`${LOG_TAG}: ${packageName} missed ${PING_TIMEOUT_THRESHOLD} pings, unregistering`)
6037
+ toRemove.push(packageName)
6038
+ continue
6039
+ }
6040
+ app.unansweredPingRounds = liveness.unansweredPingRounds
5867
6041
  }
5868
- app.unansweredPingRounds = liveness.unansweredPingRounds
5869
6042
 
5870
6043
  // Send PING — SDK auto-replies with PONG
5871
6044
  this.sendToMiniapp(packageName, {
@@ -5916,7 +6089,6 @@ class LocalMiniappRuntime {
5916
6089
  }
5917
6090
 
5918
6091
  // Belt-and-suspenders: clear any remaining state
5919
- this.pendingCloudRequests.clear()
5920
6092
  const hadButtonPressSubscribers = this.getButtonPressSubscribers().length > 0
5921
6093
  this.streamSubscribers.clear()
5922
6094
  if (hadButtonPressSubscribers) {
@@ -23,10 +23,9 @@
23
23
  * Host-side handler bodies (display fan-out, mic state, transcription,
24
24
  * navigation, etc.) live untouched in `LocalMiniappRuntime.ts`.
25
25
  *
26
- * Cloud-message routing (`phone_stream_status`, `phone_managed_stream_status`)
27
- * goes straight through `LocalMiniappRuntime.handleCloudMessage` nothing
28
- * for the router to do; the responses arrive inside an envelope whose
29
- * `sendMessage` was already registered via the same path here.
26
+ * PhoneStreamCoordinator delivers stream status through LocalMiniappRuntime
27
+ * to the miniapp's registered `sendMessage` callback, using the same local
28
+ * bridge as other responses and events.
30
29
  *
31
30
  * The router also bridges native error / log / unhandled-rejection
32
31
  * events (`iface: "__log"`, `iface: "__error"`) into the standard
@@ -551,11 +550,8 @@ export class MentraJSRouter {
551
550
 
552
551
  /**
553
552
  * Push a `kind="bridge"` envelope into the named JSContext's
554
- * `globalThis.__deliver`. Used both by the per-app `sendMessage`
555
- * registered via {@link registerApp} and by ad-hoc callers (e.g.
556
- * cloud-relayed stream-status responses route through
557
- * `LocalMiniappRuntime.handleCloudMessage`, which then calls the
558
- * `app.sendMessage(serialized)` registered above).
553
+ * `globalThis.__deliver`. Used by the per-app `sendMessage` registered via
554
+ * {@link registerApp} to deliver runtime responses and events locally.
559
555
  */
560
556
  private dispatchBridgeRaw(packageName: string, raw: string): void {
561
557
  void this.crust.mentraJsDispatchToJs(packageName, {kind: "bridge", raw})