@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
|
@@ -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
|
|
3612
|
-
* streams
|
|
3613
|
-
*
|
|
3614
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
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: () =>
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
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
|
|
4482
|
-
|
|
4483
|
-
|
|
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.
|
|
4503
|
-
*
|
|
4504
|
-
*
|
|
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")
|
|
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
|
-
|
|
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
|
-
* -
|
|
5132
|
-
* -
|
|
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
|
-
//
|
|
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
|
-
*
|
|
5460
|
+
* Normalize incoming event names to miniapp stream type values.
|
|
5295
5461
|
*/
|
|
5296
5462
|
private normalizeStreamType(cloudEventName: string): string {
|
|
5297
|
-
//
|
|
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
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
555
|
-
*
|
|
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})
|