@mentra/engine 3.2.0-dev.235 → 3.2.0-dev.245
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 +70 -0
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +97 -0
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/AppRegistry.d.ts +2 -12
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +2 -37
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/CloudClientService.d.ts +1 -1
- package/build/services/CloudClientService.d.ts.map +1 -1
- package/build/services/CloudClientService.js +2 -2
- package/build/services/CloudClientService.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +51 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +228 -6
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/PhoneNotificationsSync.d.ts +1 -1
- package/build/services/PhonePhotoCoordinator.d.ts +3 -2
- package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
- package/build/services/PhonePhotoCoordinator.js +19 -14
- package/build/services/PhonePhotoCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +64 -0
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +103 -3
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/build/services/manifestPermissions.d.ts +13 -0
- package/build/services/manifestPermissions.d.ts.map +1 -0
- package/build/services/manifestPermissions.js +42 -0
- package/build/services/manifestPermissions.js.map +1 -0
- package/build/types/applet.d.ts +13 -1
- package/build/types/applet.d.ts.map +1 -1
- package/build/types/applet.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +138 -0
- package/src/services/AppRegistry.ts +3 -40
- package/src/services/CloudClientService.ts +2 -2
- package/src/services/LocalMiniappRuntime.ts +268 -7
- package/src/services/PhonePhotoCoordinator.ts +18 -14
- package/src/services/SoftapCallTransport.ts +151 -16
- package/src/services/manifestPermissions.ts +44 -0
- package/src/types/applet.ts +11 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* This runtime handles request dispatch, stream fan-out, and lifecycle
|
|
9
9
|
* management (connect/disconnect/ping).
|
|
10
10
|
*/
|
|
11
|
+
import { parsePhotoCompression } from "@mentra/cloud-protocol/photo-compression";
|
|
11
12
|
import { acquireGlassesHotspot } from "./GlassesHotspotLease";
|
|
12
13
|
import { AppState, Linking } from "react-native";
|
|
13
14
|
import Share from "react-native-share";
|
|
@@ -58,7 +59,7 @@ import { resolveForegroundLocationPermission } from "./ForegroundLocationPermiss
|
|
|
58
59
|
import { advanceMiniappPingLiveness } from "./MiniappLiveness";
|
|
59
60
|
import { listPhoneCalendarEvents, PhoneCalendarError } from "./PhoneCalendarService";
|
|
60
61
|
import { LocalMiniappStorage } from "./LocalMiniappStorage";
|
|
61
|
-
import acsMeetingService, { parseAcsOutgoingVideo, parseAcsVideoSource, resolveAcsAudioSource, } from "./AcsMeetingService";
|
|
62
|
+
import acsMeetingService, { parseAcsCallOrigin, parseAcsOutgoingVideo, parseAcsVideoSource, resolveAcsAudioSource, } from "./AcsMeetingService";
|
|
62
63
|
import { createSoftapCallDeps, SoftapCallError, SoftapCallTransport, SOFTAP_STEPS, } from "./SoftapCallTransport";
|
|
63
64
|
import { awaitCleanupBarrier } from "./SoftapCleanupBarrier";
|
|
64
65
|
import { softapTrace, softapTraceFailure } from "../utils/softapTrace";
|
|
@@ -253,6 +254,30 @@ function resetPermissionWarnings(packageName) {
|
|
|
253
254
|
}
|
|
254
255
|
/** How long a stalled SoftAP cleanup goes unremarked before it is logged. It is never abandoned. */
|
|
255
256
|
const SOFTAP_CLEANUP_STALL_LOG_MS = 10_000;
|
|
257
|
+
/**
|
|
258
|
+
* The WHIP listener's tombstone (3s) plus a margin for the close itself.
|
|
259
|
+
*
|
|
260
|
+
* Sized to the grace period rather than picked round, so an expiry here means something actually
|
|
261
|
+
* went wrong instead of meaning we guessed short.
|
|
262
|
+
*/
|
|
263
|
+
const SOFTAP_INGEST_CLOSE_WAIT_MS = 3_500;
|
|
264
|
+
/** How long the glasses get to acknowledge their hotspot off, over BLE. */
|
|
265
|
+
const SOFTAP_HOTSPOT_OFF_ACK_MS = 5_000;
|
|
266
|
+
/** After a forced close there is nothing left to wait for, so this only catches a real failure. */
|
|
267
|
+
const SOFTAP_FORCED_VERIFY_MS = 500;
|
|
268
|
+
/** Reject with [reason] if [work] has not settled in [ms]. The work itself is not cancellable. */
|
|
269
|
+
function withTimeout(work, ms, reason) {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const timer = setTimeout(() => reject(new Error(reason)), ms);
|
|
272
|
+
work.then((value) => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
resolve(value);
|
|
275
|
+
}, (error) => {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
reject(error);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
256
281
|
/** How long the next call waits for the previous one's cleanup before telling the wearer about it. */
|
|
257
282
|
const SOFTAP_CLEANUP_NARRATE_AFTER_MS = 250;
|
|
258
283
|
// =============================================================================
|
|
@@ -269,6 +294,16 @@ class LocalMiniappRuntime {
|
|
|
269
294
|
* before the first `await` and is what every later stage checks itself against.
|
|
270
295
|
*/
|
|
271
296
|
softapAttempt = null;
|
|
297
|
+
/**
|
|
298
|
+
* The outer generation. Every async callback that can outlive an attempt checks itself against
|
|
299
|
+
* it and traces `softap_stale_callback` when it loses: ACS state, SoftAP progress, glasses
|
|
300
|
+
* stream status, scoped-network loss, and each checkpoint through the join.
|
|
301
|
+
*
|
|
302
|
+
* There are inner generations below this one — `AcsMeetingSession.joinGeneration` and
|
|
303
|
+
* `LocalWhipIngestSource.generation` — and they are not redundant. They fence work inside one
|
|
304
|
+
* native session against itself; this one fences whole attempts against each other, and only it
|
|
305
|
+
* knows that the wearer pressed Cancel before any of that native work existed.
|
|
306
|
+
*/
|
|
272
307
|
softapAttemptSeq = 0;
|
|
273
308
|
/**
|
|
274
309
|
* Why the previous SoftAP call's cleanup failed, if it did.
|
|
@@ -277,6 +312,13 @@ class LocalMiniappRuntime {
|
|
|
277
312
|
* would not turn off is precisely what makes the next call fail somewhere far less legible.
|
|
278
313
|
*/
|
|
279
314
|
softapCleanupError = null;
|
|
315
|
+
/**
|
|
316
|
+
* SoftAP teardown disables the glasses hotspot from the transport and again
|
|
317
|
+
* from the host barrier. Native used to throw `request_in_flight` on the
|
|
318
|
+
* second call, which was recorded as a cleanup failure and refused the next
|
|
319
|
+
* join. One queue so those overlapping disables wait their turn.
|
|
320
|
+
*/
|
|
321
|
+
glassesHotspotCommand = Promise.resolve();
|
|
280
322
|
/** Connected miniapps keyed by packageName. */
|
|
281
323
|
connectedApps = new Map();
|
|
282
324
|
/**
|
|
@@ -2886,6 +2928,26 @@ class LocalMiniappRuntime {
|
|
|
2886
2928
|
});
|
|
2887
2929
|
return false;
|
|
2888
2930
|
}
|
|
2931
|
+
/**
|
|
2932
|
+
* OS-permission gate: checked, never requested.
|
|
2933
|
+
*
|
|
2934
|
+
* The prompt belongs at miniapp-open time, where it can be explained. Requesting here would put
|
|
2935
|
+
* a system dialog on top of a join that has already started building a hotspot, and a wearer who
|
|
2936
|
+
* denies it gets a call that half-exists. Missing means the join is refused with the permission
|
|
2937
|
+
* named, so the miniapp can send them to Settings.
|
|
2938
|
+
*/
|
|
2939
|
+
async requireOsPermission(packageName, requestId, feature, label) {
|
|
2940
|
+
if (await permissions.check(feature))
|
|
2941
|
+
return true;
|
|
2942
|
+
softapTraceFailure("softap_permission_missing", { packageName, feature });
|
|
2943
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
2944
|
+
code: MiniappErrorCode.PERMISSION_DENIED,
|
|
2945
|
+
message: `This phone's ${label} permission is not granted. Grant it in Settings, then try again.`,
|
|
2946
|
+
permission: feature,
|
|
2947
|
+
operation: MiniappRequestType.MEETING_JOIN,
|
|
2948
|
+
});
|
|
2949
|
+
return false;
|
|
2950
|
+
}
|
|
2889
2951
|
async handlePhoto(packageName, payload, requestId) {
|
|
2890
2952
|
// Manifest CAMERA permission gate.
|
|
2891
2953
|
const app = this.connectedApps.get(packageName);
|
|
@@ -2905,7 +2967,7 @@ class LocalMiniappRuntime {
|
|
|
2905
2967
|
size: payload.size,
|
|
2906
2968
|
mode: payload.mode,
|
|
2907
2969
|
transferMethod: payload.transferMethod,
|
|
2908
|
-
compress: payload.compress,
|
|
2970
|
+
compress: parsePhotoCompression(payload.compress),
|
|
2909
2971
|
sound: payload.sound,
|
|
2910
2972
|
saveToGallery: payload.saveToGallery,
|
|
2911
2973
|
exposureTimeNs: payload.exposureTimeNs,
|
|
@@ -3136,12 +3198,24 @@ class LocalMiniappRuntime {
|
|
|
3136
3198
|
// A meeting puts the glasses camera and mic in front of remote strangers; it
|
|
3137
3199
|
// needs both declared, same as photo/stream and mic capture do individually.
|
|
3138
3200
|
if (!this.requireManifestPermission(packageName, requestId, "CAMERA", "to join a meeting", MiniappRequestType.MEETING_JOIN) ||
|
|
3139
|
-
!this.requireManifestPermission(packageName, requestId, "MICROPHONE", "to join a meeting", MiniappRequestType.MEETING_JOIN)
|
|
3201
|
+
!this.requireManifestPermission(packageName, requestId, "MICROPHONE", "to join a meeting", MiniappRequestType.MEETING_JOIN) ||
|
|
3202
|
+
!this.requireManifestPermission(packageName, requestId, "PHONE_CAMERA", "to join a meeting", MiniappRequestType.MEETING_JOIN)) {
|
|
3140
3203
|
return;
|
|
3141
3204
|
}
|
|
3205
|
+
// Defensive only: these are prompted for when the wearer opens the miniapp, so by the time a
|
|
3206
|
+
// join runs they are either granted or were refused minutes ago on a screen that explained
|
|
3207
|
+
// them. Checked and never requested — a permission dialog on top of a half-built call is
|
|
3208
|
+
// asked too late to explain itself, and ACS fails the join outright without the camera.
|
|
3209
|
+
if (!(await this.requireOsPermission(packageName, requestId, PermissionFeatures.CAMERA, "camera")))
|
|
3210
|
+
return;
|
|
3211
|
+
if (!(await this.requireOsPermission(packageName, requestId, PermissionFeatures.MICROPHONE, "microphone")))
|
|
3212
|
+
return;
|
|
3142
3213
|
const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : "";
|
|
3143
3214
|
const token = typeof payload.token === "string" ? payload.token : "";
|
|
3144
3215
|
const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined;
|
|
3216
|
+
// Diagnostic only. A miniapp that does not send it gets `unknown`, which is a third answer in
|
|
3217
|
+
// the comparison rather than a default that would quietly file every old build under "created".
|
|
3218
|
+
const origin = parseAcsCallOrigin(payload.origin);
|
|
3145
3219
|
if (!meetingUrl || !token) {
|
|
3146
3220
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3147
3221
|
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
@@ -3184,10 +3258,11 @@ class LocalMiniappRuntime {
|
|
|
3184
3258
|
transport: videoSource.type,
|
|
3185
3259
|
hasDisplayName: Boolean(displayName),
|
|
3186
3260
|
video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
|
|
3261
|
+
origin,
|
|
3187
3262
|
});
|
|
3188
3263
|
const startedAt = Date.now();
|
|
3189
3264
|
try {
|
|
3190
|
-
const state = await this.joinSoftapMeeting(packageName, { meetingUrl, token, displayName, video });
|
|
3265
|
+
const state = await this.joinSoftapMeeting(packageName, { meetingUrl, token, displayName, video, origin });
|
|
3191
3266
|
softapTrace("meeting_join_result", {
|
|
3192
3267
|
packageName,
|
|
3193
3268
|
requestId: requestId ?? "none",
|
|
@@ -3215,14 +3290,21 @@ class LocalMiniappRuntime {
|
|
|
3215
3290
|
token,
|
|
3216
3291
|
videoSource,
|
|
3217
3292
|
displayName,
|
|
3293
|
+
origin,
|
|
3218
3294
|
...(video ? { video } : {}),
|
|
3219
3295
|
});
|
|
3220
3296
|
this.sendResult(packageName, requestId, true, state);
|
|
3221
3297
|
}
|
|
3222
3298
|
catch (err) {
|
|
3299
|
+
// A SoftAP failure already knows what it was and where. Flattening every one of them to
|
|
3300
|
+
// INTERNAL is what left the miniapp unable to tell "your Wi-Fi is off" — a condition with a
|
|
3301
|
+
// two-tap fix — from an ACS timeout, so it showed the same shrug for both. The step comes
|
|
3302
|
+
// along too: the same code at `hotspot` and at `scopedJoin` are different stories.
|
|
3303
|
+
const softap = err instanceof SoftapCallError ? err : undefined;
|
|
3223
3304
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3224
|
-
code: MiniappErrorCode.INTERNAL,
|
|
3305
|
+
code: softap?.code ?? MiniappErrorCode.INTERNAL,
|
|
3225
3306
|
message: err instanceof Error ? err.message : "ACS meeting join failed",
|
|
3307
|
+
...(softap ? { step: softap.step } : {}),
|
|
3226
3308
|
});
|
|
3227
3309
|
}
|
|
3228
3310
|
}
|
|
@@ -3417,15 +3499,22 @@ class LocalMiniappRuntime {
|
|
|
3417
3499
|
meetingUrl: args.meetingUrl,
|
|
3418
3500
|
token: args.token,
|
|
3419
3501
|
displayName: args.displayName,
|
|
3502
|
+
video: args.video,
|
|
3420
3503
|
awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
|
|
3421
3504
|
subsystems: {
|
|
3422
3505
|
setHotspotState: async (enabled) => {
|
|
3423
|
-
const status = await
|
|
3506
|
+
const status = await this.setGlassesHotspotState(enabled);
|
|
3424
3507
|
if (status.state === "enabled") {
|
|
3425
3508
|
return { state: status.state, ssid: status.ssid, password: status.password, localIp: status.localIp };
|
|
3426
3509
|
}
|
|
3427
3510
|
return { state: status.state };
|
|
3428
3511
|
},
|
|
3512
|
+
isWifiEnabled: async () => {
|
|
3513
|
+
// A host that cannot answer reports "on" so the preflight stands aside; the native
|
|
3514
|
+
// throw at scopedJoin is what covers those platforms.
|
|
3515
|
+
const enabled = await acsMeetingService.isWifiEnabled();
|
|
3516
|
+
return enabled ?? true;
|
|
3517
|
+
},
|
|
3429
3518
|
joinScopedNetwork: (ssid, passphrase, gateway) => acsMeetingService.joinScopedNetwork(ssid, passphrase, gateway),
|
|
3430
3519
|
leaveScopedNetwork: () => acsMeetingService.leaveScopedNetwork(),
|
|
3431
3520
|
cancelScopedNetworkJoin: () => acsMeetingService.cancelScopedNetworkJoin(),
|
|
@@ -3437,6 +3526,18 @@ class LocalMiniappRuntime {
|
|
|
3437
3526
|
awaitValidatedDefaultNetwork: () => acsMeetingService.awaitValidatedDefaultNetwork(),
|
|
3438
3527
|
onGlassesStreamStatus: (listener) => {
|
|
3439
3528
|
const sub = BluetoothSdk.addListener("stream_status", (event) => {
|
|
3529
|
+
// The glasses narrate over BLE, which is slow enough that a status from the previous
|
|
3530
|
+
// publish can land after this attempt was retired — and it is narration, so it would
|
|
3531
|
+
// be written straight onto the new call's checklist as if it were happening now.
|
|
3532
|
+
if (this.softapAttempt !== attempt || attempt.cancelled) {
|
|
3533
|
+
softapTrace("softap_stale_callback", {
|
|
3534
|
+
source: "glasses_stream_status",
|
|
3535
|
+
attempt: attempt.id,
|
|
3536
|
+
status: event.status,
|
|
3537
|
+
current: this.softapAttempt?.id ?? "none",
|
|
3538
|
+
});
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3440
3541
|
listener({
|
|
3441
3542
|
status: event.status,
|
|
3442
3543
|
streamId: event.streamId,
|
|
@@ -3451,6 +3552,7 @@ class LocalMiniappRuntime {
|
|
|
3451
3552
|
token: options.token,
|
|
3452
3553
|
videoSource: options.videoSource,
|
|
3453
3554
|
displayName: options.displayName,
|
|
3555
|
+
origin: args.origin,
|
|
3454
3556
|
...(args.video ? { video: args.video } : {}),
|
|
3455
3557
|
}),
|
|
3456
3558
|
leaveMeeting: (pkg) => acsMeetingService.leave(pkg),
|
|
@@ -3678,8 +3780,10 @@ class LocalMiniappRuntime {
|
|
|
3678
3780
|
// Start Call's `teams:create` return `Request failed (503)` — Cloudflare, no internet, not a
|
|
3679
3781
|
// Teams configuration problem.
|
|
3680
3782
|
const defaultNetworkStartedAt = Date.now();
|
|
3783
|
+
let defaultNetworkOk = false;
|
|
3681
3784
|
try {
|
|
3682
3785
|
const network = await acsMeetingService.awaitDefaultNetworkAfterHotspot();
|
|
3786
|
+
defaultNetworkOk = true;
|
|
3683
3787
|
softapTrace("softap_teardown_step", {
|
|
3684
3788
|
attempt: attempt.id,
|
|
3685
3789
|
step: "awaitValidatedDefaultNetwork",
|
|
@@ -3704,9 +3808,12 @@ class LocalMiniappRuntime {
|
|
|
3704
3808
|
reason: error instanceof Error ? error.message : String(error),
|
|
3705
3809
|
});
|
|
3706
3810
|
}
|
|
3811
|
+
const defaultNetworkMs = Date.now() - defaultNetworkStartedAt;
|
|
3707
3812
|
const acsLeaveStartedAt = Date.now();
|
|
3813
|
+
let acsLeaveOk = false;
|
|
3708
3814
|
try {
|
|
3709
3815
|
const outcome = await acsMeetingService.leaveAndAwait(attempt.packageName);
|
|
3816
|
+
acsLeaveOk = outcome.completed;
|
|
3710
3817
|
softapTrace("softap_teardown_step", {
|
|
3711
3818
|
attempt: attempt.id,
|
|
3712
3819
|
step: "acsLeaveAndAwait",
|
|
@@ -3741,6 +3848,10 @@ class LocalMiniappRuntime {
|
|
|
3741
3848
|
step: "scopedLostListener",
|
|
3742
3849
|
outcome: hadScopedLostListener ? "ok" : "not taken",
|
|
3743
3850
|
});
|
|
3851
|
+
failures.push(...(await this.settleSoftapTeardown(attempt, {
|
|
3852
|
+
acsLeave: { ok: acsLeaveOk, durationMs: Date.now() - acsLeaveStartedAt },
|
|
3853
|
+
defaultNetwork: { ok: defaultNetworkOk, durationMs: defaultNetworkMs },
|
|
3854
|
+
})));
|
|
3744
3855
|
if (failures.length > 0) {
|
|
3745
3856
|
this.softapCleanupError = failures.join("; ");
|
|
3746
3857
|
console.warn("[LocalMiniappRuntime] softap_cleanup_failed", { attempt: attempt.id, failures });
|
|
@@ -3759,6 +3870,117 @@ class LocalMiniappRuntime {
|
|
|
3759
3870
|
if (endFailure)
|
|
3760
3871
|
throw endFailure;
|
|
3761
3872
|
}
|
|
3873
|
+
setGlassesHotspotState(enabled) {
|
|
3874
|
+
const run = this.glassesHotspotCommand.then(() => BluetoothSdk.setHotspotState(enabled), () => BluetoothSdk.setHotspotState(enabled));
|
|
3875
|
+
this.glassesHotspotCommand = run.then(() => undefined, () => undefined);
|
|
3876
|
+
return run;
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* The last two gates, and the point where a timeout stops being treated as consent.
|
|
3880
|
+
*
|
|
3881
|
+
* Four things have to be true before the next call may start: this device is out of the ACS
|
|
3882
|
+
* call, the WHIP listener's port is released, the glasses have acknowledged their hotspot off,
|
|
3883
|
+
* and the phone has a validated default route again. The first and last already ran; this adds
|
|
3884
|
+
* the two that were never waited for at all, and the ones that produced the stop-then-start
|
|
3885
|
+
* failure — the listener was still inside its 3s tombstone and the glasses AP was still up.
|
|
3886
|
+
*
|
|
3887
|
+
* A bound that expires is not success. It means the resource is still held by a call nobody is
|
|
3888
|
+
* in any more, so it is taken by force and re-verified. Only if *that* fails does the next join
|
|
3889
|
+
* get refused, which is a worse outcome than a slow teardown and a better one than two calls
|
|
3890
|
+
* sharing a port.
|
|
3891
|
+
*
|
|
3892
|
+
* @returns failures to record; a non-empty list refuses the next join.
|
|
3893
|
+
*/
|
|
3894
|
+
async settleSoftapTeardown(attempt, earlier) {
|
|
3895
|
+
const ingestStartedAt = Date.now();
|
|
3896
|
+
let ingestClosed = false;
|
|
3897
|
+
try {
|
|
3898
|
+
ingestClosed = await acsMeetingService.awaitIngestClosed(SOFTAP_INGEST_CLOSE_WAIT_MS);
|
|
3899
|
+
}
|
|
3900
|
+
catch (error) {
|
|
3901
|
+
softapTraceFailure("softap_teardown_step", {
|
|
3902
|
+
attempt: attempt.id,
|
|
3903
|
+
step: "awaitIngestClosed",
|
|
3904
|
+
outcome: "failed",
|
|
3905
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3906
|
+
});
|
|
3907
|
+
}
|
|
3908
|
+
const ingestMs = Date.now() - ingestStartedAt;
|
|
3909
|
+
const hotspotStartedAt = Date.now();
|
|
3910
|
+
let hotspotOff = false;
|
|
3911
|
+
let hotspotReason = "";
|
|
3912
|
+
try {
|
|
3913
|
+
// Idempotent by design — the command asks for "disabled" rather than toggling — so sending
|
|
3914
|
+
// it again after the transport's own stop costs nothing and is the only way to get an ack
|
|
3915
|
+
// that is bound to a deadline we chose.
|
|
3916
|
+
const status = await withTimeout(this.setGlassesHotspotState(false), SOFTAP_HOTSPOT_OFF_ACK_MS, "hotspot_off_ack_timeout");
|
|
3917
|
+
hotspotOff = status.state === "disabled";
|
|
3918
|
+
hotspotReason = status.state;
|
|
3919
|
+
}
|
|
3920
|
+
catch (error) {
|
|
3921
|
+
hotspotReason = error instanceof Error ? error.message : String(error);
|
|
3922
|
+
}
|
|
3923
|
+
const hotspotMs = Date.now() - hotspotStartedAt;
|
|
3924
|
+
softapTrace("softap_teardown_settled", {
|
|
3925
|
+
attempt: attempt.id,
|
|
3926
|
+
acsLeaveMs: earlier.acsLeave.durationMs,
|
|
3927
|
+
acsLeaveOk: earlier.acsLeave.ok,
|
|
3928
|
+
ingestClosedMs: ingestMs,
|
|
3929
|
+
ingestClosed,
|
|
3930
|
+
hotspotOffMs: hotspotMs,
|
|
3931
|
+
hotspotOff,
|
|
3932
|
+
hotspotDetail: hotspotReason,
|
|
3933
|
+
defaultNetworkMs: earlier.defaultNetwork.durationMs,
|
|
3934
|
+
defaultNetworkOk: earlier.defaultNetwork.ok,
|
|
3935
|
+
});
|
|
3936
|
+
if (ingestClosed && hotspotOff)
|
|
3937
|
+
return [];
|
|
3938
|
+
return this.forceSoftapCleanup(attempt, { ingestClosed, hotspotOff, hotspotReason });
|
|
3939
|
+
}
|
|
3940
|
+
/**
|
|
3941
|
+
* Take back what the bounded waits could not confirm was released.
|
|
3942
|
+
*
|
|
3943
|
+
* Runs only after a gate timed out, and re-verifies rather than assuming: the whole reason the
|
|
3944
|
+
* barrier exists is that "it has probably finished by now" is what broke restarts.
|
|
3945
|
+
*/
|
|
3946
|
+
async forceSoftapCleanup(attempt, gates) {
|
|
3947
|
+
const failures = [];
|
|
3948
|
+
softapTraceFailure("softap_teardown_forced", {
|
|
3949
|
+
attempt: attempt.id,
|
|
3950
|
+
ingestClosed: gates.ingestClosed,
|
|
3951
|
+
hotspotOff: gates.hotspotOff,
|
|
3952
|
+
hotspotDetail: gates.hotspotReason,
|
|
3953
|
+
});
|
|
3954
|
+
if (!gates.ingestClosed) {
|
|
3955
|
+
try {
|
|
3956
|
+
await acsMeetingService.forceCloseIngest();
|
|
3957
|
+
// Re-asked, not assumed: `forceCloseIngest` skips the tombstone, so the latch should be
|
|
3958
|
+
// open immediately. A short bound here is enough and a failure is real.
|
|
3959
|
+
const closed = await acsMeetingService.awaitIngestClosed(SOFTAP_FORCED_VERIFY_MS);
|
|
3960
|
+
if (!closed)
|
|
3961
|
+
failures.push("the video receiver did not release its port");
|
|
3962
|
+
}
|
|
3963
|
+
catch (error) {
|
|
3964
|
+
failures.push(`forced ingest close: ${error instanceof Error ? error.message : String(error)}`);
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
if (!gates.hotspotOff) {
|
|
3968
|
+
try {
|
|
3969
|
+
const status = await withTimeout(this.setGlassesHotspotState(false), SOFTAP_HOTSPOT_OFF_ACK_MS, "hotspot_off_ack_timeout");
|
|
3970
|
+
if (status.state !== "disabled")
|
|
3971
|
+
failures.push(`the glasses hotspot is still ${status.state}`);
|
|
3972
|
+
}
|
|
3973
|
+
catch (error) {
|
|
3974
|
+
failures.push(`glasses hotspot off: ${error instanceof Error ? error.message : String(error)}`);
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
softapTrace("softap_teardown_forced_done", {
|
|
3978
|
+
attempt: attempt.id,
|
|
3979
|
+
recovered: failures.length === 0,
|
|
3980
|
+
failures: failures.join("; "),
|
|
3981
|
+
});
|
|
3982
|
+
return failures;
|
|
3983
|
+
}
|
|
3762
3984
|
async handleMeetingLeave(packageName, requestId) {
|
|
3763
3985
|
const startedAt = Date.now();
|
|
3764
3986
|
softapTrace("meeting_leave_request", {
|