@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
@@ -23,7 +23,7 @@ import { storage as mmkvStorage } from "../utils/storage/storage";
23
23
  import { BgTimer } from "../utils/timers";
24
24
  import devServerBridge from "./DevServerBridge";
25
25
  import { islandNotifications } from "./NotificationsEmitter";
26
- import { isGlassesConnected } from "./GlassesReadiness";
26
+ import { isGlassesConnected, isGlassesReady, waitForGlassesReady } from "./GlassesReadiness";
27
27
  import { toMiniappConnectionData } from "./GlassesStatusProjection";
28
28
  import audioPlaybackService from "./AudioPlaybackService";
29
29
  import { phoneLocationService } from "./PhoneLocationService";
@@ -56,11 +56,11 @@ import { NavigationHandlers } from "./NavigationHandlers";
56
56
  import { useAppStatusStore } from "../stores/apps";
57
57
  import appRegistry, { getDevAppAttestation, getDevAppSourcePackage } from "./AppRegistry";
58
58
  import { resolveForegroundLocationPermission } from "./ForegroundLocationPermission";
59
- import { advanceMiniappPingLiveness } from "./MiniappLiveness";
59
+ import { advanceMiniappPingLiveness, shouldHoldMiniappPingLiveness } from "./MiniappLiveness";
60
60
  import { listPhoneCalendarEvents, PhoneCalendarError } from "./PhoneCalendarService";
61
61
  import { LocalMiniappStorage } from "./LocalMiniappStorage";
62
62
  import acsMeetingService, { parseAcsCallOrigin, parseAcsOutgoingVideo, parseAcsVideoSource, resolveAcsAudioSource, } from "./AcsMeetingService";
63
- import { createSoftapCallDeps, SoftapCallError, SoftapCallTransport, SOFTAP_STEPS, } from "./SoftapCallTransport";
63
+ import { createSoftapCallDeps, SoftapCallError, SoftapCallTransport, RETURN_DEADLINE_MS, SOFTAP_STEPS, } from "./SoftapCallTransport";
64
64
  import { awaitCleanupBarrier } from "./SoftapCleanupBarrier";
65
65
  import { softapTrace, softapTraceFailure } from "../utils/softapTrace";
66
66
  import { PermissionFeatures, permissions } from "../facades/permissions";
@@ -120,6 +120,11 @@ const FOREGROUND_LIVENESS_PROBE_TIMEOUT_MS = 2_500;
120
120
  * rather than sit in "connecting" while the wearer waits.
121
121
  */
122
122
  const SOFTAP_FIRST_FRAME_MS = 20_000;
123
+ /**
124
+ * How long the BLE link must stay up before SoftAP recovery trusts it. Covers the gap between
125
+ * the hotspot dying and the phone noticing the link died with it.
126
+ */
127
+ const GLASSES_LINK_SETTLE_MS = 4_000;
123
128
  const REQUEST_WIFI_SETUP_TYPE = "miniapp_request_wifi_setup";
124
129
  // Unregister after this many missed pongs. Generous on purpose: a busy
125
130
  // context (heavy interim translation traffic) or OS scheduling while idle can
@@ -265,6 +270,25 @@ const SOFTAP_INGEST_CLOSE_WAIT_MS = 3_500;
265
270
  const SOFTAP_HOTSPOT_OFF_ACK_MS = 5_000;
266
271
  /** After a forced close there is nothing left to wait for, so this only catches a real failure. */
267
272
  const SOFTAP_FORCED_VERIFY_MS = 500;
273
+ /**
274
+ * Rejection codes meaning the glasses replaced their Wi-Fi protocol session, not that the
275
+ * command failed.
276
+ *
277
+ * The glasses emit `wifi_protocol_session_ready` on every `glasses_ready`, which fires on BLE
278
+ * reconnects and ASG restarts — routine during a SoftAP teardown. The SDK cancels every pending
279
+ * Wi-Fi request when that lands, so a hotspot command in flight is lost even though nothing is
280
+ * actually wrong. `wifi_session_disconnected` is deliberately absent: that one is raised from the
281
+ * store update where the glasses went away, so there is nothing left to re-send to.
282
+ */
283
+ const HOTSPOT_SESSION_REFRESH_CODES = new Set(["wifi_session_restarted", "wifi_session_changed"]);
284
+ /**
285
+ * Let the restarted ASG command path settle before re-sending.
286
+ *
287
+ * `handleSetHotspotState` returns false when its network manager is not up yet, which drops the
288
+ * command silently, so an immediate re-send would just race the same initialization. Mirrors the
289
+ * retry spacing `disableHotspotWithRetry` uses for the sibling ASG-replacement case.
290
+ */
291
+ const HOTSPOT_SESSION_RETRY_DELAY_MS = 500;
268
292
  /** Reject with [reason] if [work] has not settled in [ms]. The work itself is not cancellable. */
269
293
  function withTimeout(work, ms, reason) {
270
294
  return new Promise((resolve, reject) => {
@@ -357,8 +381,6 @@ class LocalMiniappRuntime {
357
381
  * alive.
358
382
  */
359
383
  onLivenessTimeout = null;
360
- /** Pending cloud requests: requestId → packageName that originated the request. */
361
- pendingCloudRequests = new Map();
362
384
  speechRuns = new Map();
363
385
  // Browser fallback token auth — HMAC-signed blob with a phone-local
364
386
  // secret. Both issuer and verifier are the same process, so the
@@ -545,81 +567,6 @@ class LocalMiniappRuntime {
545
567
  console.log(`${LOG_TAG}: initialize()`);
546
568
  this.ensurePingLoop();
547
569
  }
548
- /**
549
- * Handle an incoming cloud message forwarded by SocketComms
550
- * (phone_stream_status, phone_managed_stream_status).
551
- *
552
- * Routes the response back to the originating miniapp via the requestId
553
- * that was stored when the miniapp first made the request.
554
- */
555
- handleCloudMessage(msg) {
556
- const requestId = msg.requestId;
557
- const msgType = msg.type;
558
- console.log(`${LOG_TAG}: Cloud message: ${msgType}, requestId=${requestId ?? "none"}`);
559
- if (!requestId) {
560
- console.warn(`${LOG_TAG}: Cloud message ${msgType} has no requestId, cannot route`);
561
- return;
562
- }
563
- const pending = this.pendingCloudRequests.get(requestId);
564
- if (!pending) {
565
- console.warn(`${LOG_TAG}: No pending request for requestId=${requestId}`);
566
- return;
567
- }
568
- this.pendingCloudRequests.delete(requestId);
569
- switch (msgType) {
570
- case "phone_stream_status": {
571
- // Unreachable for phone-orchestrated streams (the coordinator owns
572
- // their lifecycle and never registers a pending cloud request).
573
- // Retained as a safety net for any legacy registration path.
574
- this.sendToMiniapp(pending.packageName, {
575
- type: MiniappResponseType.EVENT,
576
- streamType: "stream_status",
577
- data: {
578
- streamId: msg.streamId,
579
- status: msg.status,
580
- errorDetails: msg.errorDetails,
581
- },
582
- });
583
- // Re-register for ongoing status updates (streams send multiple status messages)
584
- this.pendingCloudRequests.set(requestId, pending);
585
- break;
586
- }
587
- case "phone_managed_stream_status": {
588
- if (msg.status === "connected" || msg.status === "active") {
589
- // Managed stream is ready — send back the playback URLs as the request result
590
- this.sendResult(pending.packageName, pending.envelopeRequestId, true, {
591
- streamId: msg.streamId,
592
- hlsUrl: msg.hlsUrl,
593
- dashUrl: msg.dashUrl,
594
- webrtcUrl: msg.webrtcUrl,
595
- });
596
- }
597
- // Forward all statuses as events too
598
- this.sendToMiniapp(pending.packageName, {
599
- type: MiniappResponseType.EVENT,
600
- streamType: "stream_status",
601
- data: {
602
- streamId: msg.streamId,
603
- status: msg.status,
604
- hlsUrl: msg.hlsUrl,
605
- dashUrl: msg.dashUrl,
606
- webrtcUrl: msg.webrtcUrl,
607
- },
608
- });
609
- // Re-register for ongoing updates
610
- this.pendingCloudRequests.set(requestId, pending);
611
- break;
612
- }
613
- default:
614
- console.warn(`${LOG_TAG}: Unknown cloud message type: ${msgType}`);
615
- }
616
- }
617
- /**
618
- * Register a pending cloud request so we can route the response back.
619
- */
620
- registerPendingCloudRequest(requestId, packageName, envelopeRequestId) {
621
- this.pendingCloudRequests.set(requestId, { packageName, envelopeRequestId });
622
- }
623
570
  // ===========================================================================
624
571
  // App registration
625
572
  // ===========================================================================
@@ -872,12 +819,6 @@ class LocalMiniappRuntime {
872
819
  // field is enough.
873
820
  app.requestedLocationRate = null;
874
821
  this.recomputeLocationTier();
875
- // Clean up any pending cloud requests from this app
876
- for (const [reqId, pending] of this.pendingCloudRequests) {
877
- if (pending.packageName === packageName) {
878
- this.pendingCloudRequests.delete(reqId);
879
- }
880
- }
881
822
  // Release any display real estate this app held — if it owned the
882
823
  // current on-glasses frame, this clears the glasses (or restores the
883
824
  // core app's saved frame).
@@ -3073,10 +3014,10 @@ class LocalMiniappRuntime {
3073
3014
  }
3074
3015
  }
3075
3016
  /**
3076
- * Stream handlers — dispatched to the engine PhoneStreamCoordinator. For managed
3077
- * streams the coordinator additionally calls the v2 client REST route to
3078
- * provision Cloudflare. Cloud-SDK apps (third-party developers) use a
3079
- * separate cloud-side path that does not pass through here.
3017
+ * Stream handlers — dispatched to PhoneStreamCoordinator on the phone.
3018
+ * Managed streams also use Cloud V2 REST calls to provision resources,
3019
+ * poll ingest status, and tear down the managed stream. Miniapp JavaScript
3020
+ * runs locally in the Mentra App.
3080
3021
  */
3081
3022
  async handleStreamStart(packageName, payload, requestId) {
3082
3023
  const streaming = phoneStreamCoordinator;
@@ -3180,6 +3121,7 @@ class LocalMiniappRuntime {
3180
3121
  this.sendToMiniapp(owner, {
3181
3122
  type: MiniappResponseType.MEETING_STATE,
3182
3123
  ...state,
3124
+ ...this.softapRecoveryFields(attempt?.packageName === owner ? attempt : null),
3183
3125
  });
3184
3126
  if (attempt?.ownsResources && attempt.transport?.shouldRepublish(state.mediaSource)) {
3185
3127
  void attempt.transport.republish(state.mediaSourceReason ?? "mediaSource failed");
@@ -3365,8 +3307,12 @@ class LocalMiniappRuntime {
3365
3307
  phase: "starting",
3366
3308
  steps: SOFTAP_STEPS.map((step) => ({ step, status: "pending" })),
3367
3309
  elapsedMs: 0,
3310
+ mediaGeneration: 0,
3368
3311
  },
3369
3312
  scopedLostUnsub: null,
3313
+ recovery: null,
3314
+ recoveryActive: false,
3315
+ recoveryDeadlineAt: null,
3370
3316
  body: null,
3371
3317
  teardown: null,
3372
3318
  settled,
@@ -3431,8 +3377,21 @@ class LocalMiniappRuntime {
3431
3377
  // join *is* in progress, and `idle` would make the miniapp think the call ended.
3432
3378
  state: current.state === "idle" ? "connecting" : current.state,
3433
3379
  softap: progress,
3380
+ ...this.softapRecoveryFields(attempt),
3434
3381
  });
3435
3382
  }
3383
+ softapRecoveryFields(attempt) {
3384
+ if (!attempt?.recoveryDeadlineAt)
3385
+ return {};
3386
+ return {
3387
+ recovery: {
3388
+ active: attempt.recoveryActive,
3389
+ generation: attempt.transport?.currentMediaGeneration(),
3390
+ deadlineAt: attempt.recoveryDeadlineAt,
3391
+ phase: attempt.transport?.currentPhase(),
3392
+ },
3393
+ };
3394
+ }
3436
3395
  /**
3437
3396
  * Narrates preflight work on the first row while it is still pending.
3438
3397
  *
@@ -3471,10 +3430,12 @@ class LocalMiniappRuntime {
3471
3430
  this.checkpointSoftapAttempt(attempt, "cleanup barrier");
3472
3431
  const cleanupError = this.softapCleanupError;
3473
3432
  if (cleanupError) {
3474
- // Report once, allowing a retry after the wearer power-cycles the glasses.
3433
+ // Cleared on read, so this refuses at most once and the next attempt always gets through —
3434
+ // which is why the wearer is told to retry rather than to go power-cycle anything. The
3435
+ // native reason stays in the trace: it names a leaked port, not something they can act on.
3475
3436
  this.softapCleanupError = null;
3476
3437
  softapTraceFailure("softap_join_refused", { packageName, reason: cleanupError });
3477
- throw new Error(`Previous call cleanup failed: ${cleanupError}. Power-cycle the glasses hotspot and try again.`);
3438
+ throw new Error("Previous call cleanup has not finished yet. Please try joining again.");
3478
3439
  }
3479
3440
  attempt.releaseHotspot = acquireGlassesHotspot();
3480
3441
  attempt.ownsResources = true;
@@ -3503,7 +3464,7 @@ class LocalMiniappRuntime {
3503
3464
  token: args.token,
3504
3465
  displayName: args.displayName,
3505
3466
  video: args.video,
3506
- awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
3467
+ awaitFirstFrame: (_report, options) => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS, options),
3507
3468
  waitUntilLive: (timeoutMs) => acsMeetingService.waitUntilMediaLive(timeoutMs),
3508
3469
  subsystems: {
3509
3470
  setHotspotState: async (enabled) => {
@@ -3567,6 +3528,15 @@ class LocalMiniappRuntime {
3567
3528
  glassesLc3Uplink: () => acsMeetingService.glassesLc3UplinkActive(),
3568
3529
  startPublishing: (pkg, options) => phoneStreamCoordinator.startUnmanaged(pkg, options),
3569
3530
  stopPublishing: (pkg) => phoneStreamCoordinator.stop(pkg),
3531
+ discardPendingBleStop: () => phoneStreamCoordinator.discardPendingBleStop(),
3532
+ rebindIngest: async (report) => {
3533
+ report?.("Rebinding the glasses video receiver on the hotspot");
3534
+ const ingestUrl = await acsMeetingService.rebindSoftApIngest();
3535
+ if (!ingestUrl)
3536
+ throw new Error("the rebound meeting reported no ingest URL");
3537
+ report?.(`Receiver rebound at ${ingestUrl}`);
3538
+ return { ingestUrl };
3539
+ },
3570
3540
  },
3571
3541
  }));
3572
3542
  attempt.transport = transport;
@@ -3598,14 +3568,7 @@ class LocalMiniappRuntime {
3598
3568
  reason: error.message,
3599
3569
  attemptAgeMs: Date.now() - attempt.startedAt,
3600
3570
  });
3601
- const current = acsMeetingService.getState();
3602
- this.sendToMiniapp(packageName, {
3603
- type: MiniappResponseType.MEETING_STATE,
3604
- ...current,
3605
- state: "error",
3606
- error: `SOFTAP_NETWORK_LOST: ${error.message}`,
3607
- softap: transport.progress(),
3608
- });
3571
+ this.beginSoftapRecovery(attempt, transport, error);
3609
3572
  });
3610
3573
  // Sign in on the phone's current internet *before* the glasses hotspot takes DNS. On device,
3611
3574
  // createCallAgent after the scoped join timed out at 20s and only finished once SoftAP was
@@ -3644,6 +3607,163 @@ class LocalMiniappRuntime {
3644
3607
  });
3645
3608
  return state;
3646
3609
  }
3610
+ /**
3611
+ * SoftAP loss is a media outage. Keep ACS, destroy generation N, wait for the glasses,
3612
+ * then build generation N+1. Duplicate losses do not renew the deadline.
3613
+ */
3614
+ beginSoftapRecovery(attempt, transport, error) {
3615
+ // Gate on the live flag, not the promise: a settled Promise is still truthy, so a second
3616
+ // walk-away after a successful re-arm was dropped as "in-flight" and the glasses kept
3617
+ // POSTing WHIP at a hotspot the phone had already left.
3618
+ if (attempt.recoveryActive) {
3619
+ softapTrace("softap_recovery_dropped", { attempt: attempt.id, cause: "in-flight" });
3620
+ return;
3621
+ }
3622
+ if (attempt.cancelled || this.softapAttempt !== attempt) {
3623
+ softapTrace("softap_recovery_dropped", {
3624
+ attempt: attempt.id,
3625
+ cause: attempt.cancelled ? "retired" : "superseded",
3626
+ });
3627
+ return;
3628
+ }
3629
+ const lostAt = Date.now();
3630
+ attempt.recoveryDeadlineAt = lostAt + RETURN_DEADLINE_MS;
3631
+ attempt.recoveryActive = true;
3632
+ this.emitSoftapProgress(attempt, transport.progress());
3633
+ softapTrace("softap_recovery_begin", {
3634
+ attempt: attempt.id,
3635
+ deadlineAt: attempt.recoveryDeadlineAt,
3636
+ mediaGeneration: transport.currentMediaGeneration(),
3637
+ reason: error.message,
3638
+ });
3639
+ let run;
3640
+ run = (async () => {
3641
+ try {
3642
+ await transport.recover(`${error.code}: ${error.message}`, {
3643
+ wait: async () => {
3644
+ const remaining = (attempt.recoveryDeadlineAt ?? 0) - Date.now();
3645
+ if (remaining <= 0) {
3646
+ throw new SoftapCallError("hotspot", "RETURN_DEADLINE", "The glasses did not return in time");
3647
+ }
3648
+ const ready = await this.waitForGlassesReturn(remaining, () => attempt.cancelled);
3649
+ if (attempt.cancelled || this.softapAttempt !== attempt) {
3650
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled");
3651
+ }
3652
+ if (!ready) {
3653
+ throw new SoftapCallError("hotspot", "RETURN_DEADLINE", "The glasses did not return in time");
3654
+ }
3655
+ },
3656
+ });
3657
+ if (attempt.cancelled || this.softapAttempt !== attempt)
3658
+ return;
3659
+ this.emitSoftapProgress(attempt, transport.progress());
3660
+ const current = acsMeetingService.getState();
3661
+ this.sendToMiniapp(attempt.packageName, {
3662
+ type: MiniappResponseType.MEETING_STATE,
3663
+ ...current,
3664
+ softap: transport.progress(),
3665
+ ...this.softapRecoveryFields(attempt),
3666
+ });
3667
+ softapTrace("softap_recovery_live", {
3668
+ attempt: attempt.id,
3669
+ mediaGeneration: transport.currentMediaGeneration(),
3670
+ elapsedMs: Date.now() - lostAt,
3671
+ });
3672
+ }
3673
+ catch (recoveryError) {
3674
+ if (attempt.cancelled || this.softapAttempt !== attempt)
3675
+ return;
3676
+ const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
3677
+ softapTraceFailure("softap_recovery_failed", {
3678
+ attempt: attempt.id,
3679
+ reason: message,
3680
+ elapsedMs: Date.now() - lostAt,
3681
+ });
3682
+ const current = acsMeetingService.getState();
3683
+ this.sendToMiniapp(attempt.packageName, {
3684
+ type: MiniappResponseType.MEETING_STATE,
3685
+ ...current,
3686
+ state: "error",
3687
+ error: `SOFTAP_NETWORK_LOST: ${error.message}`,
3688
+ softap: transport.progress(),
3689
+ recovery: {
3690
+ active: false,
3691
+ generation: transport.currentMediaGeneration(),
3692
+ deadlineAt: attempt.recoveryDeadlineAt ?? undefined,
3693
+ phase: transport.currentPhase(),
3694
+ },
3695
+ });
3696
+ }
3697
+ finally {
3698
+ attempt.recoveryActive = false;
3699
+ if (attempt.recovery === run)
3700
+ attempt.recovery = null;
3701
+ }
3702
+ })();
3703
+ attempt.recovery = run;
3704
+ }
3705
+ /**
3706
+ * Wait for a link the hotspot commands can actually use.
3707
+ *
3708
+ * The hotspot goes away a couple of seconds before the phone declares the BLE link dead, so the
3709
+ * snapshot at loss time still reads "ready". Taking that at face value sent `set_hotspot_state`
3710
+ * into a dying link twice, and each one burned its full timeout before the wearer was even back.
3711
+ * Requiring the link to hold steady turns that into waiting, which is what the budget is for.
3712
+ */
3713
+ async waitForGlassesReturn(timeoutMs, cancelled) {
3714
+ const deadlineAt = Date.now() + timeoutMs;
3715
+ while (!cancelled()) {
3716
+ const remaining = deadlineAt - Date.now();
3717
+ if (remaining <= 0)
3718
+ return false;
3719
+ if (!isGlassesReady(useGlassesStore.getState().connection) && !(await this.awaitGlassesReady(remaining, cancelled))) {
3720
+ return false;
3721
+ }
3722
+ const settleMs = Math.min(GLASSES_LINK_SETTLE_MS, Math.max(0, deadlineAt - Date.now()));
3723
+ if (await this.glassesLinkHoldsSteady(settleMs, cancelled))
3724
+ return true;
3725
+ }
3726
+ return false;
3727
+ }
3728
+ awaitGlassesReady(timeoutMs, cancelled) {
3729
+ const controller = new AbortController();
3730
+ const poll = setInterval(() => {
3731
+ if (cancelled())
3732
+ controller.abort();
3733
+ }, 250);
3734
+ return waitForGlassesReady({
3735
+ getConnection: () => useGlassesStore.getState().connection,
3736
+ subscribe: (listener) => useGlassesStore.subscribe((state) => state.connection, listener),
3737
+ timeoutMs,
3738
+ signal: controller.signal,
3739
+ }).finally(() => clearInterval(poll));
3740
+ }
3741
+ /** True when the link stays ready for [ms]; false the moment it drops again. */
3742
+ glassesLinkHoldsSteady(ms, cancelled) {
3743
+ if (ms <= 0)
3744
+ return Promise.resolve(isGlassesReady(useGlassesStore.getState().connection));
3745
+ return new Promise((resolve) => {
3746
+ let done = false;
3747
+ const settle = (steady) => {
3748
+ if (done)
3749
+ return;
3750
+ done = true;
3751
+ clearTimeout(timer);
3752
+ clearInterval(poll);
3753
+ unsubscribe();
3754
+ resolve(steady);
3755
+ };
3756
+ const unsubscribe = useGlassesStore.subscribe((state) => state.connection, (connection) => {
3757
+ if (!isGlassesReady(connection))
3758
+ settle(false);
3759
+ });
3760
+ const poll = setInterval(() => {
3761
+ if (cancelled())
3762
+ settle(false);
3763
+ }, 250);
3764
+ const timer = setTimeout(() => settle(true), ms);
3765
+ });
3766
+ }
3647
3767
  /**
3648
3768
  * Cancels a SoftAP attempt and does not return until everything it owns has been released.
3649
3769
  *
@@ -3686,7 +3806,11 @@ class LocalMiniappRuntime {
3686
3806
  afterMs: SOFTAP_CLEANUP_STALL_LOG_MS,
3687
3807
  });
3688
3808
  }, SOFTAP_CLEANUP_STALL_LOG_MS);
3689
- void Promise.allSettled([attempt.teardown, attempt.body ?? Promise.resolve()]).then(() => {
3809
+ void Promise.allSettled([
3810
+ attempt.teardown,
3811
+ attempt.body ?? Promise.resolve(),
3812
+ attempt.recovery ?? Promise.resolve(),
3813
+ ]).then(() => {
3690
3814
  clearTimeout(stallWatchdog);
3691
3815
  attempt.markSettled();
3692
3816
  if (this.softapAttempt === attempt)
@@ -3773,8 +3897,17 @@ class LocalMiniappRuntime {
3773
3897
  // so this is the caller's business, not a reason to block the next call.
3774
3898
  endFailure = await releaseStep("transport.stop", () => transport.stop(mode ? { mode } : {}));
3775
3899
  const undoFailures = transport.lastTeardownFailures();
3776
- if (undoFailures.length > 0)
3777
- failures.push(`could not release ${undoFailures.join(", ")}`);
3900
+ // A hotspot that did not ACK off is leftover ON — the next join wants it on and will
3901
+ // call setHotspotState(true). Refusing that join is how we got hotspot_off_ack_timeout
3902
+ // as a wearer-facing "power-cycle the glasses" dead end.
3903
+ const blockingUndos = undoFailures.filter((step) => step !== "hotspot");
3904
+ if (blockingUndos.length > 0)
3905
+ failures.push(`could not release ${blockingUndos.join(", ")}`);
3906
+ if (undoFailures.includes("hotspot")) {
3907
+ console.warn("[LocalMiniappRuntime] glasses hotspot undo timed out; next join will raise it again", {
3908
+ attempt: attempt.id,
3909
+ });
3910
+ }
3778
3911
  }
3779
3912
  const scopedFailure = await releaseStep("leaveScopedNetwork", () => acsMeetingService.leaveScopedNetwork());
3780
3913
  if (scopedFailure) {
@@ -3874,8 +4007,32 @@ class LocalMiniappRuntime {
3874
4007
  if (endFailure)
3875
4008
  throw endFailure;
3876
4009
  }
4010
+ /**
4011
+ * Send one hotspot command, re-sending it once if a session refresh cancelled it.
4012
+ *
4013
+ * The retry lives inside the unit chained onto [glassesHotspotCommand] rather than around it,
4014
+ * which is what holds the invariant: one logical hotspot operation issues at most two native
4015
+ * commands, and no later operation runs between them. A retry outside the queue would let an
4016
+ * `on` land between the two halves of an `off` and leave the glasses in the opposite state.
4017
+ *
4018
+ * Re-sending is free because ASG compares against current state first and answers a same-state
4019
+ * command with its current status instead of cycling the AP.
4020
+ */
3877
4021
  setGlassesHotspotState(enabled) {
3878
- const run = this.glassesHotspotCommand.then(() => BluetoothSdk.setHotspotState(enabled), () => BluetoothSdk.setHotspotState(enabled));
4022
+ const send = async () => {
4023
+ try {
4024
+ return await BluetoothSdk.setHotspotState(enabled);
4025
+ }
4026
+ catch (error) {
4027
+ const code = error?.code;
4028
+ if (!code || !HOTSPOT_SESSION_REFRESH_CODES.has(code))
4029
+ throw error;
4030
+ softapTrace("softap_hotspot_session_retry", { enabled, code });
4031
+ await new Promise((resolve) => setTimeout(resolve, HOTSPOT_SESSION_RETRY_DELAY_MS));
4032
+ return await BluetoothSdk.setHotspotState(enabled);
4033
+ }
4034
+ };
4035
+ const run = this.glassesHotspotCommand.then(send, send);
3879
4036
  this.glassesHotspotCommand = run.then(() => undefined, () => undefined);
3880
4037
  return run;
3881
4038
  }
@@ -3889,9 +4046,9 @@ class LocalMiniappRuntime {
3889
4046
  * failure — the listener was still inside its 3s tombstone and the glasses AP was still up.
3890
4047
  *
3891
4048
  * A bound that expires is not success. It means the resource is still held by a call nobody is
3892
- * in any more, so it is taken by force and re-verified. Only if *that* fails does the next join
3893
- * get refused, which is a worse outcome than a slow teardown and a better one than two calls
3894
- * sharing a port.
4049
+ * in any more, so it is taken by force and re-verified. An ingest port that is still held
4050
+ * refuses the next join. A glasses hotspot that missed its off-ack does not the next join
4051
+ * wants the AP up and will raise it again.
3895
4052
  *
3896
4053
  * @returns failures to record; a non-empty list refuses the next join.
3897
4054
  */
@@ -3971,11 +4128,18 @@ class LocalMiniappRuntime {
3971
4128
  if (!gates.hotspotOff) {
3972
4129
  try {
3973
4130
  const status = await withTimeout(this.setGlassesHotspotState(false), SOFTAP_HOTSPOT_OFF_ACK_MS, "hotspot_off_ack_timeout");
3974
- if (status.state !== "disabled")
3975
- failures.push(`the glasses hotspot is still ${status.state}`);
4131
+ if (status.state !== "disabled") {
4132
+ console.warn("[LocalMiniappRuntime] glasses hotspot still reported after disable; next join will raise it", {
4133
+ attempt: attempt.id,
4134
+ state: status.state,
4135
+ });
4136
+ }
3976
4137
  }
3977
4138
  catch (error) {
3978
- failures.push(`glasses hotspot off: ${error instanceof Error ? error.message : String(error)}`);
4139
+ console.warn("[LocalMiniappRuntime] glasses hotspot off ack missed; next join will raise it", {
4140
+ attempt: attempt.id,
4141
+ reason: error instanceof Error ? error.message : String(error),
4142
+ });
3979
4143
  }
3980
4144
  }
3981
4145
  softapTrace("softap_teardown_forced_done", {
@@ -4459,11 +4623,11 @@ class LocalMiniappRuntime {
4459
4623
  * Forward a streamed event to all miniapps subscribed to the given stream.
4460
4624
  *
4461
4625
  * Event name translation:
4462
- * - Cloud sends "head_up" → miniapp protocol uses "head_position" (HEAD_POSITION)
4463
- * - Cloud sends "VAD" (uppercase) → miniapp protocol uses "vad" (lowercase)
4626
+ * - Incoming "head_up" → miniapp protocol uses "head_position" (HEAD_POSITION)
4627
+ * - Incoming "VAD" (uppercase) → miniapp protocol uses "vad" (lowercase)
4464
4628
  */
4465
4629
  forwardEvent(streamType, data, transcriptionSource) {
4466
- // Translate cloud event names to miniapp protocol stream types
4630
+ // Normalize incoming event names to miniapp protocol stream types
4467
4631
  const normalizedStream = this.normalizeStreamType(streamType);
4468
4632
  // Collect all subscribers: exact match, plus wildcard matches for streams
4469
4633
  // that carry a language tag. A miniapp subscribed to "transcription:auto"
@@ -4606,10 +4770,10 @@ class LocalMiniappRuntime {
4606
4770
  };
4607
4771
  }
4608
4772
  /**
4609
- * Translate cloud event names to miniapp stream type values.
4773
+ * Normalize incoming event names to miniapp stream type values.
4610
4774
  */
4611
4775
  normalizeStreamType(cloudEventName) {
4612
- // Cloud / Bluetooth SDK → miniapp protocol translations.
4776
+ // Incoming event names → miniapp protocol translations.
4613
4777
  // Bluetooth SDK event names don't always match the miniapp wire values.
4614
4778
  switch (cloudEventName) {
4615
4779
  case "head_up":
@@ -5115,13 +5279,20 @@ class LocalMiniappRuntime {
5115
5279
  doPingRound() {
5116
5280
  const toRemove = [];
5117
5281
  for (const [packageName, app] of this.connectedApps) {
5118
- const liveness = advanceMiniappPingLiveness(app.unansweredPingRounds, PING_TIMEOUT_THRESHOLD);
5119
- if (liveness.shouldUnregister) {
5120
- console.warn(`${LOG_TAG}: ${packageName} missed ${PING_TIMEOUT_THRESHOLD} pings, unregistering`);
5121
- toRemove.push(packageName);
5122
- continue;
5282
+ const holdPingLiveness = shouldHoldMiniappPingLiveness({
5283
+ packageName,
5284
+ softapPackageName: this.softapAttempt?.packageName,
5285
+ softapCancelled: this.softapAttempt?.cancelled,
5286
+ });
5287
+ if (!holdPingLiveness) {
5288
+ const liveness = advanceMiniappPingLiveness(app.unansweredPingRounds, PING_TIMEOUT_THRESHOLD);
5289
+ if (liveness.shouldUnregister) {
5290
+ console.warn(`${LOG_TAG}: ${packageName} missed ${PING_TIMEOUT_THRESHOLD} pings, unregistering`);
5291
+ toRemove.push(packageName);
5292
+ continue;
5293
+ }
5294
+ app.unansweredPingRounds = liveness.unansweredPingRounds;
5123
5295
  }
5124
- app.unansweredPingRounds = liveness.unansweredPingRounds;
5125
5296
  // Send PING — SDK auto-replies with PONG
5126
5297
  this.sendToMiniapp(packageName, {
5127
5298
  type: MiniappRequestType.PING,
@@ -5165,7 +5336,6 @@ class LocalMiniappRuntime {
5165
5336
  this.unregisterApp(pkg);
5166
5337
  }
5167
5338
  // Belt-and-suspenders: clear any remaining state
5168
- this.pendingCloudRequests.clear();
5169
5339
  const hadButtonPressSubscribers = this.getButtonPressSubscribers().length > 0;
5170
5340
  this.streamSubscribers.clear();
5171
5341
  if (hadButtonPressSubscribers) {