@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.
- package/build/facades/displayMirror.js +1 -1
- package/build/facades/displayMirror.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +25 -1
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +41 -3
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/DisplayProcessor.d.ts +1 -1
- package/build/services/DisplayProcessor.js.map +1 -1
- package/build/services/LocalDisplayManager.d.ts +0 -4
- package/build/services/LocalDisplayManager.d.ts.map +1 -1
- package/build/services/LocalDisplayManager.js +2 -7
- package/build/services/LocalDisplayManager.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +39 -24
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +293 -123
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MentraJSRouter.d.ts +5 -9
- package/build/services/MentraJSRouter.d.ts.map +1 -1
- package/build/services/MentraJSRouter.js +5 -9
- package/build/services/MentraJSRouter.js.map +1 -1
- package/build/services/MentraUIRouter.d.ts +9 -12
- package/build/services/MentraUIRouter.d.ts.map +1 -1
- package/build/services/MentraUIRouter.js +9 -12
- package/build/services/MentraUIRouter.js.map +1 -1
- package/build/services/MiniappLauncher.d.ts +6 -9
- package/build/services/MiniappLauncher.d.ts.map +1 -1
- package/build/services/MiniappLauncher.js +6 -9
- package/build/services/MiniappLauncher.js.map +1 -1
- package/build/services/MiniappLiveness.d.ts +11 -0
- package/build/services/MiniappLiveness.d.ts.map +1 -1
- package/build/services/MiniappLiveness.js +11 -0
- package/build/services/MiniappLiveness.js.map +1 -1
- package/build/services/PhoneLocationService.d.ts +2 -2
- package/build/services/PhoneLocationService.d.ts.map +1 -1
- package/build/services/PhoneLocationService.js +5 -9
- package/build/services/PhoneLocationService.js.map +1 -1
- package/build/services/PhoneStreamCoordinator.d.ts +14 -3
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +28 -6
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +87 -7
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +229 -28
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/build/services/slimStreamStatus.d.ts +4 -1
- package/build/services/slimStreamStatus.d.ts.map +1 -1
- package/build/services/slimStreamStatus.js +4 -1
- package/build/services/slimStreamStatus.js.map +1 -1
- package/build/stores/apps.js +2 -2
- package/build/stores/apps.js.map +1 -1
- package/build/utils/miniappGlobals.d.ts +3 -5
- package/build/utils/miniappGlobals.d.ts.map +1 -1
- package/build/utils/miniappGlobals.js +16 -16
- package/build/utils/miniappGlobals.js.map +1 -1
- package/package.json +8 -8
- package/src/facades/displayMirror.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +51 -3
- package/src/services/DisplayProcessor.ts +1 -1
- package/src/services/LocalDisplayManager.ts +2 -7
- package/src/services/LocalMiniappRuntime.ts +305 -133
- package/src/services/MentraJSRouter.ts +5 -9
- package/src/services/MentraUIRouter.ts +9 -12
- package/src/services/MiniappLauncher.ts +6 -9
- package/src/services/MiniappLiveness.ts +18 -0
- package/src/services/PhoneLocationService.ts +5 -9
- package/src/services/PhoneStreamCoordinator.ts +28 -7
- package/src/services/SoftapCallTransport.ts +274 -32
- package/src/services/slimStreamStatus.ts +4 -1
- package/src/stores/apps.ts +2 -2
- 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
|
|
3077
|
-
* streams
|
|
3078
|
-
*
|
|
3079
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
3777
|
-
|
|
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
|
|
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.
|
|
3893
|
-
*
|
|
3894
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* -
|
|
4463
|
-
* -
|
|
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
|
-
//
|
|
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
|
-
*
|
|
4773
|
+
* Normalize incoming event names to miniapp stream type values.
|
|
4610
4774
|
*/
|
|
4611
4775
|
normalizeStreamType(cloudEventName) {
|
|
4612
|
-
//
|
|
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
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
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) {
|