@mentra/engine 3.2.0-dev.226 → 3.2.0-dev.232
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 +15 -7
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +32 -9
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/GlassesHotspotLease.d.ts +2 -0
- package/build/services/GlassesHotspotLease.d.ts.map +1 -0
- package/build/services/GlassesHotspotLease.js +13 -0
- package/build/services/GlassesHotspotLease.js.map +1 -0
- package/build/services/LocalMiniappRuntime.d.ts +2 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +35 -11
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/ManagedWebRtcRelay.d.ts +87 -0
- package/build/services/ManagedWebRtcRelay.d.ts.map +1 -0
- package/build/services/ManagedWebRtcRelay.js +239 -0
- package/build/services/ManagedWebRtcRelay.js.map +1 -0
- package/build/services/PhoneStreamCoordinator.d.ts +5 -0
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +131 -68
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +5 -1
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +23 -5
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/package.json +8 -7
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +38 -9
- package/src/services/GlassesHotspotLease.ts +11 -0
- package/src/services/LocalMiniappRuntime.ts +47 -18
- package/src/services/ManagedWebRtcRelay.ts +278 -0
- package/src/services/PhoneStreamCoordinator.ts +152 -70
- package/src/services/SoftapCallTransport.ts +28 -7
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* management (connect/disconnect/ping).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import {acquireGlassesHotspot} from "./GlassesHotspotLease"
|
|
12
13
|
import {AppState, Linking} from "react-native"
|
|
13
14
|
import Share from "react-native-share"
|
|
14
15
|
import * as Battery from "expo-battery"
|
|
@@ -68,7 +69,12 @@ import {
|
|
|
68
69
|
} from "../runtime/config"
|
|
69
70
|
import {getAnalytics, getMiniappConfiguration, getUiSeams, isFeatureEnabled} from "../runtime/bootstrap"
|
|
70
71
|
import {invokeScanQrSeam} from "../runtime/scanQrSeam"
|
|
71
|
-
import {
|
|
72
|
+
import {
|
|
73
|
+
normalizeStreamAudioConfig,
|
|
74
|
+
normalizeStreamVideoConfig,
|
|
75
|
+
normalizeCaptureAudio,
|
|
76
|
+
resolveCaptureAudio,
|
|
77
|
+
} from "../runtime/streamConfig"
|
|
72
78
|
import {toLanguageHint} from "@mentra/cloud-protocol/languages"
|
|
73
79
|
import type {AudioSubscription, LanguageSource, TranscriptionData, TranslationData} from "@mentra/cloud-protocol"
|
|
74
80
|
import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
|
|
@@ -399,6 +405,7 @@ type SoftapAttempt = {
|
|
|
399
405
|
/** Set by leave, end, or a superseding join. Checked after every await the join performs. */
|
|
400
406
|
cancelled: boolean
|
|
401
407
|
/** Set only after the preceding attempt has fully settled. */
|
|
408
|
+
releaseHotspot?: () => void
|
|
402
409
|
ownsResources: boolean
|
|
403
410
|
transport: SoftapCallTransport | null
|
|
404
411
|
/** The checklist as the miniapp last saw it; preflight rows live here until `start()` takes over. */
|
|
@@ -991,13 +998,14 @@ class LocalMiniappRuntime {
|
|
|
991
998
|
// remain active for another subscriber; a rejected FOV release then stops
|
|
992
999
|
// renewing the departed owner's lease and ASG restores it once idle.
|
|
993
1000
|
const cameraCleanup = Promise.allSettled([
|
|
1001
|
+
this.leaveMeetingForApp(packageName),
|
|
994
1002
|
phonePhotoCoordinator.stopWarmUpForApp(packageName),
|
|
995
1003
|
phoneStreamCoordinator.stop(packageName),
|
|
996
1004
|
phoneVideoCoordinator.stopForApp(packageName),
|
|
997
1005
|
]).then((results) => {
|
|
998
1006
|
for (const result of results) {
|
|
999
1007
|
if (result.status === "rejected") {
|
|
1000
|
-
console.warn(`${LOG_TAG}: failed to stop
|
|
1008
|
+
console.warn(`${LOG_TAG}: failed to stop miniapp resource for ${packageName} on unregister`, result.reason)
|
|
1001
1009
|
}
|
|
1002
1010
|
}
|
|
1003
1011
|
})
|
|
@@ -1031,10 +1039,6 @@ class LocalMiniappRuntime {
|
|
|
1031
1039
|
// so a crashed/closed miniapp doesn't leak partial files or file handles.
|
|
1032
1040
|
this.blobStore.onAppGone(packageName)
|
|
1033
1041
|
|
|
1034
|
-
void acsMeetingService.leaveIfOwner(packageName).catch((error) => {
|
|
1035
|
-
console.warn(`${LOG_TAG}: failed to leave ACS meeting for ${packageName} on unregister`, error)
|
|
1036
|
-
})
|
|
1037
|
-
|
|
1038
1042
|
// Detach the per-app nav event forwarder but leave the native nav session
|
|
1039
1043
|
// running. The user may have just closed the mini-app UI and will reopen
|
|
1040
1044
|
// it; stopping the session here would kill an active trip mid-route.
|
|
@@ -2327,11 +2331,11 @@ class LocalMiniappRuntime {
|
|
|
2327
2331
|
typeof rawText === "string"
|
|
2328
2332
|
? [rawText.trim()].filter(Boolean)
|
|
2329
2333
|
: Array.isArray(rawText)
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2334
|
+
? rawText
|
|
2335
|
+
.filter((sentence): sentence is string => typeof sentence === "string")
|
|
2336
|
+
.map((sentence) => sentence.trim())
|
|
2337
|
+
.filter(Boolean)
|
|
2338
|
+
: []
|
|
2335
2339
|
const enableSanitization = payload.enableSanitization !== false
|
|
2336
2340
|
const sentences = prepareTtsSentences(rawSentences, enableSanitization)
|
|
2337
2341
|
if (sentences.length === 0) {
|
|
@@ -3627,13 +3631,18 @@ class LocalMiniappRuntime {
|
|
|
3627
3631
|
return
|
|
3628
3632
|
}
|
|
3629
3633
|
try {
|
|
3634
|
+
if (payload.ingest === "whip" && !(await permissions.check(PermissionFeatures.LOCAL_WIFI))) {
|
|
3635
|
+
if (!(await permissions.request(PermissionFeatures.LOCAL_WIFI))) {
|
|
3636
|
+
throw new Error("Nearby devices permission is required to join the glasses hotspot")
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3630
3639
|
const result = await streaming.startManaged(packageName, {
|
|
3631
3640
|
restreamDestinations: payload.restreamDestinations as Array<string | {url: string; name?: string}> | undefined,
|
|
3632
3641
|
video: normalizeStreamVideoConfig(payload.video),
|
|
3633
3642
|
audio: normalizeStreamAudioConfig(payload.audio),
|
|
3634
3643
|
sound: payload.sound as boolean | undefined,
|
|
3635
|
-
ingest: payload.ingest as "srt" | "whip" | undefined,
|
|
3636
|
-
captureAudio:
|
|
3644
|
+
ingest: payload.ingest as "srt" | "whip" | "rtmp" | undefined,
|
|
3645
|
+
captureAudio: normalizeCaptureAudio(payload.captureAudio),
|
|
3637
3646
|
})
|
|
3638
3647
|
this.sendResult(packageName, requestId, true, result)
|
|
3639
3648
|
} catch (err) {
|
|
@@ -3656,6 +3665,9 @@ class LocalMiniappRuntime {
|
|
|
3656
3665
|
|
|
3657
3666
|
private ensureMeetingStateBridge(): void {
|
|
3658
3667
|
acsMeetingService.setStateHandler((owner, state) => {
|
|
3668
|
+
const attempt = this.softapAttempt
|
|
3669
|
+
// While a replacement waits for cleanup, native events still describe its predecessor.
|
|
3670
|
+
if (attempt?.packageName === owner && !attempt.ownsResources) return
|
|
3659
3671
|
this.sendToMiniapp(owner, {
|
|
3660
3672
|
type: MiniappResponseType.MEETING_STATE,
|
|
3661
3673
|
...state,
|
|
@@ -3663,6 +3675,16 @@ class LocalMiniappRuntime {
|
|
|
3663
3675
|
})
|
|
3664
3676
|
}
|
|
3665
3677
|
|
|
3678
|
+
/** Startup owns the hotspot before ACS has an owner. Closing the app must retire both. */
|
|
3679
|
+
private async leaveMeetingForApp(packageName: string): Promise<void> {
|
|
3680
|
+
const attempt = this.softapAttempt
|
|
3681
|
+
if (attempt?.packageName === packageName) {
|
|
3682
|
+
await this.retireSoftapAttempt({attempt})
|
|
3683
|
+
} else {
|
|
3684
|
+
await acsMeetingService.leaveIfOwner(packageName)
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3666
3688
|
private async handleMeetingJoin(
|
|
3667
3689
|
packageName: string,
|
|
3668
3690
|
payload: Record<string, unknown>,
|
|
@@ -3891,7 +3913,8 @@ class LocalMiniappRuntime {
|
|
|
3891
3913
|
return
|
|
3892
3914
|
}
|
|
3893
3915
|
attempt.progress = progress
|
|
3894
|
-
|
|
3916
|
+
// A reservation waiting behind another call must not inherit that call's connected state.
|
|
3917
|
+
const current = attempt.transport ? acsMeetingService.getState() : {state: "connecting", muted: false}
|
|
3895
3918
|
this.sendToMiniapp(attempt.packageName, {
|
|
3896
3919
|
type: MiniappResponseType.MEETING_STATE,
|
|
3897
3920
|
...current,
|
|
@@ -3925,6 +3948,7 @@ class LocalMiniappRuntime {
|
|
|
3925
3948
|
): Promise<MeetingState> {
|
|
3926
3949
|
const packageName = attempt.packageName
|
|
3927
3950
|
if (previous) {
|
|
3951
|
+
this.narrateSoftapPreflight(attempt, "Finishing the previous call’s cleanup…")
|
|
3928
3952
|
softapTrace("softap_join_superseding", {
|
|
3929
3953
|
previousAttempt: previous.id,
|
|
3930
3954
|
previousAgeMs: Date.now() - previous.startedAt,
|
|
@@ -3949,6 +3973,7 @@ class LocalMiniappRuntime {
|
|
|
3949
3973
|
softapTraceFailure("softap_join_refused", {packageName, reason: cleanupError})
|
|
3950
3974
|
throw new Error(`Previous call cleanup failed: ${cleanupError}. Power-cycle the glasses hotspot and try again.`)
|
|
3951
3975
|
}
|
|
3976
|
+
attempt.releaseHotspot = acquireGlassesHotspot()
|
|
3952
3977
|
attempt.ownsResources = true
|
|
3953
3978
|
this.narrateSoftapPreflight(attempt, "Checking the Nearby devices permission…")
|
|
3954
3979
|
const permissionStartedAt = Date.now()
|
|
@@ -3980,12 +4005,14 @@ class LocalMiniappRuntime {
|
|
|
3980
4005
|
setHotspotState: async (enabled) => {
|
|
3981
4006
|
const status = await BluetoothSdk.setHotspotState(enabled)
|
|
3982
4007
|
if (status.state === "enabled") {
|
|
3983
|
-
return {state: status.state, ssid: status.ssid, password: status.password}
|
|
4008
|
+
return {state: status.state, ssid: status.ssid, password: status.password, localIp: status.localIp}
|
|
3984
4009
|
}
|
|
3985
4010
|
return {state: status.state}
|
|
3986
4011
|
},
|
|
3987
|
-
joinScopedNetwork: (ssid, passphrase) =>
|
|
4012
|
+
joinScopedNetwork: (ssid, passphrase, gateway) =>
|
|
4013
|
+
acsMeetingService.joinScopedNetwork(ssid, passphrase, gateway),
|
|
3988
4014
|
leaveScopedNetwork: () => acsMeetingService.leaveScopedNetwork(),
|
|
4015
|
+
cancelScopedNetworkJoin: () => acsMeetingService.cancelScopedNetworkJoin(),
|
|
3989
4016
|
probeGateway: async () => {
|
|
3990
4017
|
const verdict = await acsMeetingService.probeScopedGateway()
|
|
3991
4018
|
// No native probe means no verdict, and the orchestrator must not read that as "down".
|
|
@@ -4237,7 +4264,7 @@ class LocalMiniappRuntime {
|
|
|
4237
4264
|
// Teams configuration problem.
|
|
4238
4265
|
const defaultNetworkStartedAt = Date.now()
|
|
4239
4266
|
try {
|
|
4240
|
-
const network = await acsMeetingService.
|
|
4267
|
+
const network = await acsMeetingService.awaitDefaultNetworkAfterHotspot()
|
|
4241
4268
|
softapTrace("softap_teardown_step", {
|
|
4242
4269
|
attempt: attempt.id,
|
|
4243
4270
|
step: "awaitValidatedDefaultNetwork",
|
|
@@ -4310,6 +4337,8 @@ class LocalMiniappRuntime {
|
|
|
4310
4337
|
failures: failures.join("; "),
|
|
4311
4338
|
endRefused: Boolean(endFailure),
|
|
4312
4339
|
})
|
|
4340
|
+
attempt.releaseHotspot?.()
|
|
4341
|
+
attempt.releaseHotspot = undefined
|
|
4313
4342
|
if (endFailure) throw endFailure
|
|
4314
4343
|
}
|
|
4315
4344
|
|
|
@@ -4437,7 +4466,7 @@ class LocalMiniappRuntime {
|
|
|
4437
4466
|
requestId?: string,
|
|
4438
4467
|
): Promise<void> {
|
|
4439
4468
|
const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
|
|
4440
|
-
const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : ""
|
|
4469
|
+
const whepUrl = videoSource?.type === "whep" ? (videoSource.url ?? "") : ""
|
|
4441
4470
|
if (!whepUrl) {
|
|
4442
4471
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
4443
4472
|
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
|
|
2
|
+
import type {StreamStartRequest, StreamStatusEvent} from "@mentra/bluetooth-sdk/internal"
|
|
3
|
+
import {acquireGlassesHotspot} from "./GlassesHotspotLease"
|
|
4
|
+
|
|
5
|
+
export interface RelayOptions {
|
|
6
|
+
streamId: string
|
|
7
|
+
ingestUrl: string
|
|
8
|
+
video?: StreamStartRequest["video"]
|
|
9
|
+
audio?: StreamStartRequest["audio"]
|
|
10
|
+
captureAudio?: boolean
|
|
11
|
+
sound?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface NativeRelayEvent {
|
|
15
|
+
attemptId: string
|
|
16
|
+
state: string
|
|
17
|
+
reason: string
|
|
18
|
+
}
|
|
19
|
+
interface NativeRelay {
|
|
20
|
+
prepare(options: {
|
|
21
|
+
attemptId: string
|
|
22
|
+
ingestUrl: string
|
|
23
|
+
ssid: string
|
|
24
|
+
password: string
|
|
25
|
+
gatewayAddress?: string
|
|
26
|
+
captureAudio: boolean
|
|
27
|
+
bitrate: number
|
|
28
|
+
}): Promise<string>
|
|
29
|
+
stop(attemptId: string): Promise<void>
|
|
30
|
+
addListener(event: "onRelayState", listener: (event: NativeRelayEvent) => void): {remove(): void}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RelayDependencies {
|
|
34
|
+
native: NativeRelay
|
|
35
|
+
hotspot(enabled: boolean): Promise<{state: string; ssid?: string; password?: string; localIp?: string}>
|
|
36
|
+
startGlasses(request: StreamStartRequest): Promise<StreamStatusEvent | undefined>
|
|
37
|
+
stopGlasses(): Promise<unknown>
|
|
38
|
+
deferredStop(): void
|
|
39
|
+
connected(): boolean
|
|
40
|
+
sleep(ms: number): Promise<void>
|
|
41
|
+
now(): number
|
|
42
|
+
acquire(): () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ManagedRelay {
|
|
46
|
+
start(): Promise<StreamStatusEvent | undefined>
|
|
47
|
+
cancel(): void
|
|
48
|
+
stop(): Promise<void>
|
|
49
|
+
owns(streamId: string): boolean
|
|
50
|
+
handleGlassesStatus(event: StreamStatusEvent): void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One attempt at a time; media stays native. All retries unwind both peers and the hotspot. */
|
|
54
|
+
export class ManagedWebRtcRelay implements ManagedRelay {
|
|
55
|
+
private cancelled = false
|
|
56
|
+
private started = false
|
|
57
|
+
private attempt = 0
|
|
58
|
+
private attemptId: string | null = null
|
|
59
|
+
private attemptError: Error | null = null
|
|
60
|
+
private hotspotTouched = false
|
|
61
|
+
private nativeTouched = false
|
|
62
|
+
private glassesTouched = false
|
|
63
|
+
private release: (() => void) | null = null
|
|
64
|
+
private listener: {remove(): void} | null = null
|
|
65
|
+
private operation: Promise<unknown> = Promise.resolve()
|
|
66
|
+
private restarting = false
|
|
67
|
+
private retries = 0
|
|
68
|
+
private readyAt: number | null = null
|
|
69
|
+
private stopping: Promise<void> | null = null
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly options: RelayOptions,
|
|
73
|
+
private readonly onStatus: (status: string, reason: string) => void,
|
|
74
|
+
private readonly onFailure: (error: Error) => void,
|
|
75
|
+
private readonly deps: RelayDependencies,
|
|
76
|
+
) {}
|
|
77
|
+
|
|
78
|
+
start(): Promise<StreamStatusEvent | undefined> {
|
|
79
|
+
if (this.started) return Promise.reject(new Error("Relay already started"))
|
|
80
|
+
this.started = true
|
|
81
|
+
this.release = this.deps.acquire()
|
|
82
|
+
this.listener = this.deps.native.addListener("onRelayState", (event) => {
|
|
83
|
+
if (event.attemptId !== this.attemptId || this.cancelled) return
|
|
84
|
+
if (event.state === "failed") this.failed(new Error(event.reason))
|
|
85
|
+
else this.onStatus(event.state, event.reason)
|
|
86
|
+
})
|
|
87
|
+
const start = this.startAttempt()
|
|
88
|
+
this.operation = start
|
|
89
|
+
return start
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
cancel(): void {
|
|
93
|
+
this.cancelled = true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
owns(streamId: string): boolean {
|
|
97
|
+
// Consume late status from all attempts without allowing it to affect the current attempt.
|
|
98
|
+
return streamId.startsWith(`${this.options.streamId}-relay-`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
handleGlassesStatus(event: StreamStatusEvent): void {
|
|
102
|
+
if (event.streamId !== this.attemptId || this.cancelled) return
|
|
103
|
+
if (event.status === "stopped" || event.terminal) {
|
|
104
|
+
this.failed(new Error("Glasses publisher stopped"))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private checkpoint(): void {
|
|
109
|
+
if (this.cancelled) throw new Error("Relay cancelled")
|
|
110
|
+
if (this.attemptError) throw this.attemptError
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async startAttempt(): Promise<StreamStatusEvent | undefined> {
|
|
114
|
+
this.attemptId = `${this.options.streamId}-relay-${++this.attempt}`
|
|
115
|
+
this.attemptError = null
|
|
116
|
+
this.checkpoint()
|
|
117
|
+
this.hotspotTouched = true // A timed-out BLE command may still have enabled the AP.
|
|
118
|
+
const hotspot = await this.deps.hotspot(true)
|
|
119
|
+
this.checkpoint()
|
|
120
|
+
if (hotspot.state !== "enabled" || !hotspot.ssid || !hotspot.password)
|
|
121
|
+
throw new Error("Glasses hotspot did not start")
|
|
122
|
+
await this.deps.sleep(3_000) // Same beacon/DHCP startup allowance as ACS.
|
|
123
|
+
this.checkpoint()
|
|
124
|
+
this.nativeTouched = true
|
|
125
|
+
const url = await this.deps.native.prepare({
|
|
126
|
+
attemptId: this.attemptId,
|
|
127
|
+
ingestUrl: this.options.ingestUrl,
|
|
128
|
+
ssid: hotspot.ssid,
|
|
129
|
+
password: hotspot.password,
|
|
130
|
+
gatewayAddress: hotspot.localIp,
|
|
131
|
+
captureAudio: this.options.captureAudio !== false,
|
|
132
|
+
bitrate: this.options.video?.bitrate ?? 2_000_000,
|
|
133
|
+
})
|
|
134
|
+
this.checkpoint()
|
|
135
|
+
this.glassesTouched = true
|
|
136
|
+
const result = await this.deps.startGlasses({
|
|
137
|
+
type: "start_stream",
|
|
138
|
+
streamId: this.attemptId,
|
|
139
|
+
streamUrl: url,
|
|
140
|
+
ice: {stun: ""},
|
|
141
|
+
sound: this.options.sound ?? true,
|
|
142
|
+
captureAudio: this.options.captureAudio !== false,
|
|
143
|
+
...(this.options.video !== undefined ? {video: this.options.video} : {}),
|
|
144
|
+
...(this.options.audio !== undefined ? {audio: this.options.audio} : {}),
|
|
145
|
+
})
|
|
146
|
+
this.checkpoint()
|
|
147
|
+
this.readyAt = this.deps.now()
|
|
148
|
+
return result
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private failed(error: Error): void {
|
|
152
|
+
if (this.cancelled || this.attemptError) return
|
|
153
|
+
this.attemptError = error
|
|
154
|
+
// Bound consecutive trouble, not the lifetime number of recoveries in a long stream.
|
|
155
|
+
if (this.readyAt !== null && this.deps.now() - this.readyAt >= 60_000) this.retries = 0
|
|
156
|
+
this.readyAt = null
|
|
157
|
+
if (this.restarting) return
|
|
158
|
+
this.restarting = true
|
|
159
|
+
// Await startup before cleanup: late native/BLE success still belongs to this attempt.
|
|
160
|
+
this.operation = this.operation
|
|
161
|
+
.catch(() => undefined)
|
|
162
|
+
.then(async () => {
|
|
163
|
+
while (!this.cancelled) {
|
|
164
|
+
try {
|
|
165
|
+
await this.cleanupAttempt()
|
|
166
|
+
if (this.cancelled) return
|
|
167
|
+
if (this.retries >= 3) throw error
|
|
168
|
+
this.onStatus("reconnecting", error.message)
|
|
169
|
+
await this.deps.sleep(1_000 * 2 ** this.retries++)
|
|
170
|
+
if (this.cancelled) return
|
|
171
|
+
await this.startAttempt()
|
|
172
|
+
this.onStatus("reconnected", "Glasses relay restarted")
|
|
173
|
+
return
|
|
174
|
+
} catch (failure) {
|
|
175
|
+
// Cleanup failure retains ownership; never create another stream on uncertain state.
|
|
176
|
+
if (this.hotspotTouched || this.nativeTouched || this.glassesTouched) {
|
|
177
|
+
try {
|
|
178
|
+
await this.cleanupAttempt()
|
|
179
|
+
} catch (cleanupError) {
|
|
180
|
+
if (!this.cancelled) this.onFailure(asError(cleanupError))
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (this.retries >= 3) {
|
|
185
|
+
if (!this.cancelled) this.onFailure(asError(failure))
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
.finally(() => {
|
|
192
|
+
this.restarting = false
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
stop(): Promise<void> {
|
|
197
|
+
this.cancel()
|
|
198
|
+
if (this.stopping) return this.stopping
|
|
199
|
+
this.listener?.remove()
|
|
200
|
+
this.listener = null
|
|
201
|
+
this.stopping = this.operation
|
|
202
|
+
.catch(() => undefined)
|
|
203
|
+
.then(async () => {
|
|
204
|
+
await this.cleanupAttempt()
|
|
205
|
+
this.release?.()
|
|
206
|
+
this.release = null
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
this.stopping = null
|
|
210
|
+
})
|
|
211
|
+
return this.stopping
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async cleanupAttempt(): Promise<void> {
|
|
215
|
+
const failures: unknown[] = []
|
|
216
|
+
const step = async (action: () => Promise<void>) => {
|
|
217
|
+
try {
|
|
218
|
+
await action()
|
|
219
|
+
} catch (error) {
|
|
220
|
+
failures.push(error)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Invalidate callbacks before intentionally stopping the glasses or dropping the network.
|
|
224
|
+
const id = this.attemptId
|
|
225
|
+
this.attemptId = null
|
|
226
|
+
if (this.glassesTouched)
|
|
227
|
+
await step(async () => {
|
|
228
|
+
if (this.deps.connected()) await this.deps.stopGlasses()
|
|
229
|
+
else this.deps.deferredStop()
|
|
230
|
+
this.glassesTouched = false
|
|
231
|
+
})
|
|
232
|
+
if (this.nativeTouched && id)
|
|
233
|
+
await step(async () => {
|
|
234
|
+
await this.deps.native.stop(id)
|
|
235
|
+
this.nativeTouched = false
|
|
236
|
+
})
|
|
237
|
+
if (this.hotspotTouched)
|
|
238
|
+
await step(async () => {
|
|
239
|
+
if (this.deps.connected()) {
|
|
240
|
+
const result = await this.deps.hotspot(false)
|
|
241
|
+
if (result.state !== "disabled") throw new Error("Glasses hotspot shutdown was not confirmed")
|
|
242
|
+
} else this.deps.deferredStop()
|
|
243
|
+
this.hotspotTouched = false
|
|
244
|
+
})
|
|
245
|
+
if (failures.length) {
|
|
246
|
+
this.attemptId = id // Retain the native attempt token for a subsequent cleanup retry.
|
|
247
|
+
throw new Error(`Relay cleanup failed: ${failures.map((error) => asError(error).message).join("; ")}`)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function asError(error: unknown): Error {
|
|
253
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createManagedWebRtcRelay(
|
|
257
|
+
options: RelayOptions,
|
|
258
|
+
onStatus: (status: string, reason: string) => void,
|
|
259
|
+
onFailure: (error: Error) => void,
|
|
260
|
+
connected: () => boolean,
|
|
261
|
+
deferredStop: () => void,
|
|
262
|
+
): ManagedRelay {
|
|
263
|
+
// Load only for managed WHIP. Hosts using direct SRT/RTMP need no relay module.
|
|
264
|
+
const {requireNativeModule} = require("expo-modules-core") as typeof import("expo-modules-core")
|
|
265
|
+
const {BgTimer} = require("../utils/timers") as typeof import("../utils/timers")
|
|
266
|
+
const native = requireNativeModule<NativeRelay>("MentraGlassesMediaRelay")
|
|
267
|
+
return new ManagedWebRtcRelay(options, onStatus, onFailure, {
|
|
268
|
+
native,
|
|
269
|
+
hotspot: (enabled) => BluetoothSdk.setHotspotState(enabled),
|
|
270
|
+
startGlasses: (request) => BluetoothSdk.startStream(request),
|
|
271
|
+
stopGlasses: () => BluetoothSdk.stopStream(),
|
|
272
|
+
connected,
|
|
273
|
+
deferredStop,
|
|
274
|
+
now: Date.now,
|
|
275
|
+
sleep: (ms) => new Promise((resolve) => BgTimer.setTimeout(resolve, ms)),
|
|
276
|
+
acquire: acquireGlassesHotspot,
|
|
277
|
+
})
|
|
278
|
+
}
|