@mentra/engine 3.2.0-dev.261 → 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/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/LocalMiniappRuntime.d.ts +32 -3
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +284 -30
- package/build/services/LocalMiniappRuntime.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/PhoneStreamCoordinator.d.ts +13 -1
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +27 -4
- 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/utils/miniappGlobals.d.ts.map +1 -1
- package/build/utils/miniappGlobals.js +13 -11
- package/build/utils/miniappGlobals.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +51 -3
- package/src/services/LocalMiniappRuntime.ts +296 -30
- package/src/services/MiniappLiveness.ts +18 -0
- package/src/services/PhoneStreamCoordinator.ts +27 -5
- package/src/services/SoftapCallTransport.ts +274 -32
- package/src/utils/miniappGlobals.ts +13 -11
|
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
|
|
|
12
12
|
export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "3.2.0",
|
|
15
|
-
"releaseIdentity": "3.2.0-dev.
|
|
16
|
-
"releaseSetId": "mentra-3.2.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
18
|
-
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.
|
|
19
|
-
"otaManifestSha256": "
|
|
15
|
+
"releaseIdentity": "3.2.0-dev.262",
|
|
16
|
+
"releaseSetId": "mentra-3.2.0-dev.262",
|
|
17
|
+
"sourceCommit": "545ed70c7afa46ca09b895d2dcd651b45510dbb5",
|
|
18
|
+
"otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.262.json",
|
|
19
|
+
"otaManifestSha256": "d1c4e63ae2c1cd8de479d4bbc57219d2b85e904c26a491441265f5182220125f"
|
|
20
20
|
})
|
|
@@ -382,6 +382,11 @@ type NativeModule = {
|
|
|
382
382
|
updateVideoSource(whepUrl: string): Promise<void>
|
|
383
383
|
/** Force a WHEP rebuild on the current URL. Absent on natives that predate it. */
|
|
384
384
|
restartVideoSource?(): Promise<void>
|
|
385
|
+
/**
|
|
386
|
+
* SoftAP: destroy the current ingest listener and bind a new one. Resolves with
|
|
387
|
+
* the new URL. Absent on natives that predate rebind; disabled natives reject.
|
|
388
|
+
*/
|
|
389
|
+
rebindSoftApIngest?(): Promise<string>
|
|
385
390
|
/**
|
|
386
391
|
* Join the glasses hotspot as a scoped, internet-less network; resolves with this phone's
|
|
387
392
|
* address on it. Absent on natives that predate SoftAP.
|
|
@@ -569,6 +574,13 @@ class AcsMeetingService {
|
|
|
569
574
|
private lastMediaRestartAt = 0
|
|
570
575
|
/** Callers parked in [waitForFirstFrame], woken by the next `mediaSource` verdict. */
|
|
571
576
|
private readonly firstFrameWaiters = new Set<(error?: Error) => void>()
|
|
577
|
+
/**
|
|
578
|
+
* Bumped by [invalidateDecodedMedia]. A `live` verdict only counts for the wait that
|
|
579
|
+
* started after that bump if [liveEpoch] matches.
|
|
580
|
+
*/
|
|
581
|
+
private decodedMediaEpoch = 0
|
|
582
|
+
/** [decodedMediaEpoch] at the moment the phone last reported `mediaSource: live`. */
|
|
583
|
+
private liveEpoch = -1
|
|
572
584
|
/** Mid-call republish waiters: live resolves true, leave/timeout resolves false. Failed is ignored. */
|
|
573
585
|
private readonly mediaLiveWaiters = new Set<(live: boolean) => void>()
|
|
574
586
|
private scopedLostSub: {remove: () => void} | null = null
|
|
@@ -657,6 +669,32 @@ class AcsMeetingService {
|
|
|
657
669
|
await getNative()?.forceCloseIngest?.()
|
|
658
670
|
}
|
|
659
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Destroy the current SoftAP ingest listener and bind a new one. The caller
|
|
674
|
+
* rejoins the hotspot first; native reads the current scoped address.
|
|
675
|
+
*/
|
|
676
|
+
async rebindSoftApIngest(): Promise<string> {
|
|
677
|
+
const native = getNative()
|
|
678
|
+
if (!native?.rebindSoftApIngest) {
|
|
679
|
+
throw new Error("SoftAP ingest rebind is not available on this native")
|
|
680
|
+
}
|
|
681
|
+
this.invalidateDecodedMedia()
|
|
682
|
+
const url = await native.rebindSoftApIngest()
|
|
683
|
+
this.ingestUrl = url
|
|
684
|
+
return url
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Forget a previously decoded first frame. Recovery must wait for a frame
|
|
689
|
+
* from the new ingest generation, not short-circuit on the last live verdict.
|
|
690
|
+
*/
|
|
691
|
+
invalidateDecodedMedia(): void {
|
|
692
|
+
this.decodedMediaEpoch++
|
|
693
|
+
if (this.lastState.mediaSource === "live") {
|
|
694
|
+
this.lastState = {...this.lastState, mediaSource: "connecting", mediaSourceReason: "ingest_rebind"}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
660
698
|
/**
|
|
661
699
|
* Is this phone's Wi-Fi radio on?
|
|
662
700
|
*
|
|
@@ -795,9 +833,18 @@ class AcsMeetingService {
|
|
|
795
833
|
*
|
|
796
834
|
* @param timeoutMs how long to wait before treating the silence as a failure
|
|
797
835
|
*/
|
|
798
|
-
waitForFirstFrame(timeoutMs: number): Promise<void> {
|
|
799
|
-
|
|
800
|
-
|
|
836
|
+
waitForFirstFrame(timeoutMs: number, options: {fresh?: boolean} = {}): Promise<void> {
|
|
837
|
+
const liveThisGeneration =
|
|
838
|
+
this.lastState.mediaSource === "live" && this.liveEpoch === this.decodedMediaEpoch
|
|
839
|
+
// Join short-circuits on any live. Recovery (`fresh`) short-circuits only on a live
|
|
840
|
+
// from this ingest generation: the new WHIP first frame often lands during publish,
|
|
841
|
+
// before this wait starts. Waiting for a later `onState` then times out while video
|
|
842
|
+
// is already flowing, and the miniapp hangs up the ACS meeting.
|
|
843
|
+
if (liveThisGeneration) {
|
|
844
|
+
softapTrace(
|
|
845
|
+
options.fresh ? "glasses_first_frame_this_generation" : "glasses_first_frame_already_received",
|
|
846
|
+
{mediaSource: this.lastState.mediaSource, epoch: this.decodedMediaEpoch},
|
|
847
|
+
)
|
|
801
848
|
return Promise.resolve()
|
|
802
849
|
}
|
|
803
850
|
const startedAt = Date.now()
|
|
@@ -1542,6 +1589,7 @@ class AcsMeetingService {
|
|
|
1542
1589
|
participants: participants?.length,
|
|
1543
1590
|
endReason: endReason ? `${endReason.code ?? "?"}/${endReason.subcode ?? "?"}` : undefined,
|
|
1544
1591
|
})
|
|
1592
|
+
if (mediaSource === "live") this.liveEpoch = this.decodedMediaEpoch
|
|
1545
1593
|
this.settleFirstFrameWaiters(mediaSource)
|
|
1546
1594
|
this.onState?.(packageName, state)
|
|
1547
1595
|
// A remote hang-up, an ACS error or a dropped call never goes through `leave`, so without
|
|
@@ -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) => {
|
|
@@ -3654,6 +3689,7 @@ class LocalMiniappRuntime {
|
|
|
3654
3689
|
this.sendToMiniapp(owner, {
|
|
3655
3690
|
type: MiniappResponseType.MEETING_STATE,
|
|
3656
3691
|
...state,
|
|
3692
|
+
...this.softapRecoveryFields(attempt?.packageName === owner ? attempt : null),
|
|
3657
3693
|
})
|
|
3658
3694
|
if (attempt?.ownsResources && attempt.transport?.shouldRepublish(state.mediaSource)) {
|
|
3659
3695
|
void attempt.transport.republish(state.mediaSourceReason ?? "mediaSource failed")
|
|
@@ -3865,8 +3901,12 @@ class LocalMiniappRuntime {
|
|
|
3865
3901
|
phase: "starting",
|
|
3866
3902
|
steps: SOFTAP_STEPS.map((step) => ({step, status: "pending" as const})),
|
|
3867
3903
|
elapsedMs: 0,
|
|
3904
|
+
mediaGeneration: 0,
|
|
3868
3905
|
},
|
|
3869
3906
|
scopedLostUnsub: null,
|
|
3907
|
+
recovery: null,
|
|
3908
|
+
recoveryActive: false,
|
|
3909
|
+
recoveryDeadlineAt: null,
|
|
3870
3910
|
body: null,
|
|
3871
3911
|
teardown: null,
|
|
3872
3912
|
settled,
|
|
@@ -3932,9 +3972,27 @@ class LocalMiniappRuntime {
|
|
|
3932
3972
|
// join *is* in progress, and `idle` would make the miniapp think the call ended.
|
|
3933
3973
|
state: current.state === "idle" ? "connecting" : current.state,
|
|
3934
3974
|
softap: progress,
|
|
3975
|
+
...this.softapRecoveryFields(attempt),
|
|
3935
3976
|
})
|
|
3936
3977
|
}
|
|
3937
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
|
+
|
|
3938
3996
|
/**
|
|
3939
3997
|
* Narrates preflight work on the first row while it is still pending.
|
|
3940
3998
|
*
|
|
@@ -3978,10 +4036,12 @@ class LocalMiniappRuntime {
|
|
|
3978
4036
|
this.checkpointSoftapAttempt(attempt, "cleanup barrier")
|
|
3979
4037
|
const cleanupError = this.softapCleanupError
|
|
3980
4038
|
if (cleanupError) {
|
|
3981
|
-
//
|
|
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.
|
|
3982
4042
|
this.softapCleanupError = null
|
|
3983
4043
|
softapTraceFailure("softap_join_refused", {packageName, reason: cleanupError})
|
|
3984
|
-
throw new Error(
|
|
4044
|
+
throw new Error("Previous call cleanup has not finished yet. Please try joining again.")
|
|
3985
4045
|
}
|
|
3986
4046
|
attempt.releaseHotspot = acquireGlassesHotspot()
|
|
3987
4047
|
attempt.ownsResources = true
|
|
@@ -4011,7 +4071,8 @@ class LocalMiniappRuntime {
|
|
|
4011
4071
|
token: args.token,
|
|
4012
4072
|
displayName: args.displayName,
|
|
4013
4073
|
video: args.video,
|
|
4014
|
-
awaitFirstFrame: () =>
|
|
4074
|
+
awaitFirstFrame: (_report, options) =>
|
|
4075
|
+
acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS, options),
|
|
4015
4076
|
waitUntilLive: (timeoutMs) => acsMeetingService.waitUntilMediaLive(timeoutMs),
|
|
4016
4077
|
subsystems: {
|
|
4017
4078
|
setHotspotState: async (enabled) => {
|
|
@@ -4077,6 +4138,14 @@ class LocalMiniappRuntime {
|
|
|
4077
4138
|
glassesLc3Uplink: () => acsMeetingService.glassesLc3UplinkActive(),
|
|
4078
4139
|
startPublishing: (pkg, options) => phoneStreamCoordinator.startUnmanaged(pkg, options),
|
|
4079
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
|
+
},
|
|
4080
4149
|
},
|
|
4081
4150
|
}),
|
|
4082
4151
|
)
|
|
@@ -4109,14 +4178,7 @@ class LocalMiniappRuntime {
|
|
|
4109
4178
|
reason: error.message,
|
|
4110
4179
|
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
4111
4180
|
})
|
|
4112
|
-
|
|
4113
|
-
this.sendToMiniapp(packageName, {
|
|
4114
|
-
type: MiniappResponseType.MEETING_STATE,
|
|
4115
|
-
...current,
|
|
4116
|
-
state: "error",
|
|
4117
|
-
error: `SOFTAP_NETWORK_LOST: ${error.message}`,
|
|
4118
|
-
softap: transport.progress(),
|
|
4119
|
-
})
|
|
4181
|
+
this.beginSoftapRecovery(attempt, transport, error)
|
|
4120
4182
|
})
|
|
4121
4183
|
|
|
4122
4184
|
// Sign in on the phone's current internet *before* the glasses hotspot takes DNS. On device,
|
|
@@ -4156,6 +4218,163 @@ class LocalMiniappRuntime {
|
|
|
4156
4218
|
return state
|
|
4157
4219
|
}
|
|
4158
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
|
+
|
|
4159
4378
|
/**
|
|
4160
4379
|
* Cancels a SoftAP attempt and does not return until everything it owns has been released.
|
|
4161
4380
|
*
|
|
@@ -4198,7 +4417,11 @@ class LocalMiniappRuntime {
|
|
|
4198
4417
|
afterMs: SOFTAP_CLEANUP_STALL_LOG_MS,
|
|
4199
4418
|
})
|
|
4200
4419
|
}, SOFTAP_CLEANUP_STALL_LOG_MS)
|
|
4201
|
-
void Promise.allSettled([
|
|
4420
|
+
void Promise.allSettled([
|
|
4421
|
+
attempt.teardown,
|
|
4422
|
+
attempt.body ?? Promise.resolve(),
|
|
4423
|
+
attempt.recovery ?? Promise.resolve(),
|
|
4424
|
+
]).then(() => {
|
|
4202
4425
|
clearTimeout(stallWatchdog)
|
|
4203
4426
|
attempt.markSettled()
|
|
4204
4427
|
if (this.softapAttempt === attempt) this.softapAttempt = null
|
|
@@ -4282,7 +4505,16 @@ class LocalMiniappRuntime {
|
|
|
4282
4505
|
// so this is the caller's business, not a reason to block the next call.
|
|
4283
4506
|
endFailure = await releaseStep("transport.stop", () => transport.stop(mode ? {mode} : {}))
|
|
4284
4507
|
const undoFailures = transport.lastTeardownFailures()
|
|
4285
|
-
|
|
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
|
+
}
|
|
4286
4518
|
}
|
|
4287
4519
|
const scopedFailure = await releaseStep("leaveScopedNetwork", () => acsMeetingService.leaveScopedNetwork())
|
|
4288
4520
|
if (scopedFailure) {
|
|
@@ -4384,11 +4616,30 @@ class LocalMiniappRuntime {
|
|
|
4384
4616
|
if (endFailure) throw endFailure
|
|
4385
4617
|
}
|
|
4386
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
|
+
*/
|
|
4387
4630
|
private setGlassesHotspotState(enabled: boolean) {
|
|
4388
|
-
const
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
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)
|
|
4392
4643
|
this.glassesHotspotCommand = run.then(
|
|
4393
4644
|
() => undefined,
|
|
4394
4645
|
() => undefined,
|
|
@@ -4406,9 +4657,9 @@ class LocalMiniappRuntime {
|
|
|
4406
4657
|
* failure — the listener was still inside its 3s tombstone and the glasses AP was still up.
|
|
4407
4658
|
*
|
|
4408
4659
|
* A bound that expires is not success. It means the resource is still held by a call nobody is
|
|
4409
|
-
* in any more, so it is taken by force and re-verified.
|
|
4410
|
-
*
|
|
4411
|
-
*
|
|
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.
|
|
4412
4663
|
*
|
|
4413
4664
|
* @returns failures to record; a non-empty list refuses the next join.
|
|
4414
4665
|
*/
|
|
@@ -4501,9 +4752,17 @@ class LocalMiniappRuntime {
|
|
|
4501
4752
|
SOFTAP_HOTSPOT_OFF_ACK_MS,
|
|
4502
4753
|
"hotspot_off_ack_timeout",
|
|
4503
4754
|
)
|
|
4504
|
-
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
|
+
}
|
|
4505
4761
|
} catch (error) {
|
|
4506
|
-
|
|
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
|
+
})
|
|
4507
4766
|
}
|
|
4508
4767
|
}
|
|
4509
4768
|
softapTrace("softap_teardown_forced_done", {
|
|
@@ -5766,13 +6025,20 @@ class LocalMiniappRuntime {
|
|
|
5766
6025
|
const toRemove: string[] = []
|
|
5767
6026
|
|
|
5768
6027
|
for (const [packageName, app] of this.connectedApps) {
|
|
5769
|
-
const
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
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
|
|
5774
6041
|
}
|
|
5775
|
-
app.unansweredPingRounds = liveness.unansweredPingRounds
|
|
5776
6042
|
|
|
5777
6043
|
// Send PING — SDK auto-replies with PONG
|
|
5778
6044
|
this.sendToMiniapp(packageName, {
|
|
@@ -3,6 +3,24 @@ export interface MiniappPingLivenessDecision {
|
|
|
3
3
|
unansweredPingRounds: number
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* SoftAP join can spend a minute in native `prepareAgent` before the glasses
|
|
8
|
+
* hotspot even turns on. The JS bundle stays alive but may miss pongs while
|
|
9
|
+
* ACS is on the native thread. Unregistering for missed pings in that window
|
|
10
|
+
* cancels the join and leaves the UI stuck on the hotspot row.
|
|
11
|
+
*/
|
|
12
|
+
export function shouldHoldMiniappPingLiveness(args: {
|
|
13
|
+
packageName: string
|
|
14
|
+
softapPackageName?: string | null
|
|
15
|
+
softapCancelled?: boolean
|
|
16
|
+
}): boolean {
|
|
17
|
+
return Boolean(
|
|
18
|
+
args.softapPackageName &&
|
|
19
|
+
args.softapPackageName === args.packageName &&
|
|
20
|
+
!args.softapCancelled,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
/**
|
|
7
25
|
* Advance liveness by one scheduled ping round.
|
|
8
26
|
*
|