@mentra/engine 3.2.0-dev.221 → 3.2.0-dev.222
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/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +360 -10
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +836 -19
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/AudioPlaybackService.d.ts +6 -0
- package/build/services/AudioPlaybackService.d.ts.map +1 -1
- package/build/services/AudioPlaybackService.js +4 -3
- package/build/services/AudioPlaybackService.js.map +1 -1
- package/build/services/GlassesMicProbe.d.ts +78 -0
- package/build/services/GlassesMicProbe.d.ts.map +1 -0
- package/build/services/GlassesMicProbe.js +258 -0
- package/build/services/GlassesMicProbe.js.map +1 -0
- package/build/services/LocalMiniappRuntime.d.ts +83 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +714 -39
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MentraJSLogPipeline.d.ts +39 -0
- package/build/services/MentraJSLogPipeline.d.ts.map +1 -1
- package/build/services/MentraJSLogPipeline.js +59 -3
- package/build/services/MentraJSLogPipeline.js.map +1 -1
- package/build/services/MentraJSRouter.d.ts +1 -1
- package/build/services/MentraJSRouter.d.ts.map +1 -1
- package/build/services/MentraJSRouter.js +2 -2
- package/build/services/MentraJSRouter.js.map +1 -1
- package/build/services/MicStateCoordinator.d.ts +22 -0
- package/build/services/MicStateCoordinator.d.ts.map +1 -1
- package/build/services/MicStateCoordinator.js +34 -3
- package/build/services/MicStateCoordinator.js.map +1 -1
- package/build/services/PhoneStreamCoordinator.d.ts +8 -0
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +2 -0
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +365 -0
- package/build/services/SoftapCallTransport.d.ts.map +1 -0
- package/build/services/SoftapCallTransport.js +722 -0
- package/build/services/SoftapCallTransport.js.map +1 -0
- package/build/services/SoftapCleanupBarrier.d.ts +45 -0
- package/build/services/SoftapCleanupBarrier.d.ts.map +1 -0
- package/build/services/SoftapCleanupBarrier.js +51 -0
- package/build/services/SoftapCleanupBarrier.js.map +1 -0
- package/build/utils/pcm16.d.ts +35 -0
- package/build/utils/pcm16.d.ts.map +1 -0
- package/build/utils/pcm16.js +85 -0
- package/build/utils/pcm16.js.map +1 -0
- package/build/utils/softapTrace.d.ts +39 -0
- package/build/utils/softapTrace.d.ts.map +1 -0
- package/build/utils/softapTrace.js +124 -0
- package/build/utils/softapTrace.js.map +1 -0
- package/package.json +7 -7
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +2 -0
- package/src/services/AcsMeetingService.ts +956 -22
- package/src/services/AudioPlaybackService.ts +12 -3
- package/src/services/GlassesMicProbe.ts +300 -0
- package/src/services/LocalMiniappRuntime.ts +775 -42
- package/src/services/MentraJSLogPipeline.ts +70 -3
- package/src/services/MentraJSRouter.ts +2 -2
- package/src/services/MicStateCoordinator.ts +35 -3
- package/src/services/PhoneStreamCoordinator.ts +10 -0
- package/src/services/SoftapCallTransport.ts +928 -0
- package/src/services/SoftapCleanupBarrier.ts +72 -0
- package/src/utils/pcm16.ts +93 -0
- package/src/utils/softapTrace.ts +133 -0
|
@@ -57,7 +57,11 @@ import { resolveForegroundLocationPermission } from "./ForegroundLocationPermiss
|
|
|
57
57
|
import { advanceMiniappPingLiveness } from "./MiniappLiveness";
|
|
58
58
|
import { listPhoneCalendarEvents, PhoneCalendarError } from "./PhoneCalendarService";
|
|
59
59
|
import { LocalMiniappStorage } from "./LocalMiniappStorage";
|
|
60
|
-
import acsMeetingService, { parseAcsOutgoingVideo, resolveAcsAudioSource } from "./AcsMeetingService";
|
|
60
|
+
import acsMeetingService, { parseAcsOutgoingVideo, parseAcsVideoSource, resolveAcsAudioSource, } from "./AcsMeetingService";
|
|
61
|
+
import { createSoftapCallDeps, SoftapCallError, SoftapCallTransport, SOFTAP_STEPS, } from "./SoftapCallTransport";
|
|
62
|
+
import { awaitCleanupBarrier } from "./SoftapCleanupBarrier";
|
|
63
|
+
import { softapTrace, softapTraceFailure } from "../utils/softapTrace";
|
|
64
|
+
import { PermissionFeatures, permissions } from "../facades/permissions";
|
|
61
65
|
/**
|
|
62
66
|
* Strictness ordering for `location_stream` rate values. Higher index =
|
|
63
67
|
* higher accuracy and higher power cost. The aggregate tier applied to
|
|
@@ -75,20 +79,6 @@ function locationRateRank(rate) {
|
|
|
75
79
|
const i = LOCATION_RATE_PRIORITY.indexOf(rate);
|
|
76
80
|
return i >= 0 ? i : LOCATION_RATE_PRIORITY.indexOf("passive");
|
|
77
81
|
}
|
|
78
|
-
function withTimeout(promise, timeoutMs) {
|
|
79
|
-
return new Promise((resolve) => {
|
|
80
|
-
let settled = false;
|
|
81
|
-
const done = (value) => {
|
|
82
|
-
if (settled)
|
|
83
|
-
return;
|
|
84
|
-
settled = true;
|
|
85
|
-
BgTimer.clearTimeout(timer);
|
|
86
|
-
resolve(value);
|
|
87
|
-
};
|
|
88
|
-
const timer = BgTimer.setTimeout(() => done(null), timeoutMs);
|
|
89
|
-
promise.then((value) => done(value), () => done(null));
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
82
|
const LOG_TAG = "LOCAL_MINIAPP";
|
|
93
83
|
const DIAGNOSTIC_MAX_LIST_ITEMS = 100;
|
|
94
84
|
const DIAGNOSTIC_MAX_STRING_LENGTH = 512;
|
|
@@ -120,6 +110,14 @@ const MINIAPP_AUTH_REFRESH_MIN_DELAY_MS = 5_000;
|
|
|
120
110
|
const MINIAPP_AUTH_RETRY_BASE_MS = 1_000;
|
|
121
111
|
const MINIAPP_AUTH_RETRY_MAX_MS = 15_000;
|
|
122
112
|
const FOREGROUND_LIVENESS_PROBE_TIMEOUT_MS = 2_500;
|
|
113
|
+
/**
|
|
114
|
+
* How long a SoftAP call waits for the first frame to reach ACS before failing the join.
|
|
115
|
+
*
|
|
116
|
+
* Generous because it covers the glasses' whole publish path — WHIP POST, host-only ICE, and the
|
|
117
|
+
* first decode — but bounded, since a call that connects and never paints has to fail as a join
|
|
118
|
+
* rather than sit in "connecting" while the wearer waits.
|
|
119
|
+
*/
|
|
120
|
+
const SOFTAP_FIRST_FRAME_MS = 20_000;
|
|
123
121
|
const REQUEST_WIFI_SETUP_TYPE = "miniapp_request_wifi_setup";
|
|
124
122
|
// Unregister after this many missed pongs. Generous on purpose: a busy
|
|
125
123
|
// context (heavy interim translation traffic) or OS scheduling while idle can
|
|
@@ -252,11 +250,32 @@ function resetPermissionWarnings(packageName) {
|
|
|
252
250
|
warnedPermission.delete(key);
|
|
253
251
|
}
|
|
254
252
|
}
|
|
253
|
+
/** How long a stalled SoftAP cleanup goes unremarked before it is logged. It is never abandoned. */
|
|
254
|
+
const SOFTAP_CLEANUP_STALL_LOG_MS = 10_000;
|
|
255
|
+
/** How long the next call waits for the previous one's cleanup before telling the wearer about it. */
|
|
256
|
+
const SOFTAP_CLEANUP_NARRATE_AFTER_MS = 250;
|
|
255
257
|
// =============================================================================
|
|
256
258
|
// LocalMiniappRuntime
|
|
257
259
|
// =============================================================================
|
|
258
260
|
class LocalMiniappRuntime {
|
|
259
261
|
static instance = null;
|
|
262
|
+
/**
|
|
263
|
+
* The SoftAP call being started or running, if any.
|
|
264
|
+
*
|
|
265
|
+
* An *attempt*, not a transport, because the cancellable part of a SoftAP call begins long
|
|
266
|
+
* before the transport exists: a Nearby-devices prompt and an ACS sign-in run first, and a
|
|
267
|
+
* wearer who taps Cancel during either of them has to be obeyed. The attempt is registered
|
|
268
|
+
* before the first `await` and is what every later stage checks itself against.
|
|
269
|
+
*/
|
|
270
|
+
softapAttempt = null;
|
|
271
|
+
softapAttemptSeq = 0;
|
|
272
|
+
/**
|
|
273
|
+
* Why the previous SoftAP call's cleanup failed, if it did.
|
|
274
|
+
*
|
|
275
|
+
* Read once by the next join, which refuses rather than building on leaked state: a hotspot that
|
|
276
|
+
* would not turn off is precisely what makes the next call fail somewhere far less legible.
|
|
277
|
+
*/
|
|
278
|
+
softapCleanupError = null;
|
|
260
279
|
/** Connected miniapps keyed by packageName. */
|
|
261
280
|
connectedApps = new Map();
|
|
262
281
|
/**
|
|
@@ -1107,6 +1126,9 @@ class LocalMiniappRuntime {
|
|
|
1107
1126
|
case MiniappRequestType.MEETING_LEAVE:
|
|
1108
1127
|
void this.handleMeetingLeave(packageName, requestId);
|
|
1109
1128
|
break;
|
|
1129
|
+
case MiniappRequestType.MEETING_END:
|
|
1130
|
+
void this.handleMeetingEnd(packageName, requestId);
|
|
1131
|
+
break;
|
|
1110
1132
|
case MiniappRequestType.MEETING_SET_MUTED:
|
|
1111
1133
|
void this.handleMeetingSetMuted(packageName, payload, requestId);
|
|
1112
1134
|
break;
|
|
@@ -1166,7 +1188,7 @@ class LocalMiniappRuntime {
|
|
|
1166
1188
|
// Register if not already
|
|
1167
1189
|
const existing = this.connectedApps.get(packageName);
|
|
1168
1190
|
if (!existing) {
|
|
1169
|
-
console.warn(`${LOG_TAG}: CONNECT from unregistered app ${packageName},
|
|
1191
|
+
console.warn(`${LOG_TAG}: CONNECT from unregistered app ${packageName}, dropping — isolate already torn down`);
|
|
1170
1192
|
return;
|
|
1171
1193
|
}
|
|
1172
1194
|
// Update lastPongAt so it doesn't time out right away
|
|
@@ -1187,32 +1209,20 @@ class LocalMiniappRuntime {
|
|
|
1187
1209
|
existing.authDelivered = false;
|
|
1188
1210
|
this.clearMiniappAuthDeliveryRetry(packageName);
|
|
1189
1211
|
const authPromise = this.requestMiniappAuth(packageName);
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
this.scheduleMiniappAuthRefresh(packageName, initialAuth);
|
|
1195
|
-
}
|
|
1212
|
+
// Send CONNECT_ACK in this turn, before any await. A 1.5s auth wait let a
|
|
1213
|
+
// concurrent unregister (dev respawn, tray stop, glasses-not-ready teardown)
|
|
1214
|
+
// drop the ACK; Mentra Call then dies with CONNECT_ACK timeout even though
|
|
1215
|
+
// CONNECT landed. Auth still arrives on AUTH_UPDATE.
|
|
1196
1216
|
this.sendToMiniapp(packageName, {
|
|
1197
1217
|
type: MiniappResponseType.CONNECT_ACK,
|
|
1198
|
-
userId,
|
|
1218
|
+
userId: "",
|
|
1199
1219
|
packageName,
|
|
1200
1220
|
capabilities,
|
|
1201
1221
|
permissions: declaredPermissions,
|
|
1202
1222
|
configuration: getMiniappConfiguration(packageName),
|
|
1203
1223
|
hostFeatures: { captureAudio: true },
|
|
1204
|
-
...(initialAuth ? { auth: initialAuth } : {}),
|
|
1205
1224
|
}, requestId);
|
|
1206
|
-
|
|
1207
|
-
// The mint timed out or (more importantly) REJECTED — the latter happens
|
|
1208
|
-
// when the cloud client hasn't finished its first-boot Core token exchange
|
|
1209
|
-
// yet, which is exactly the case when a dev miniapp is scanned/launched
|
|
1210
|
-
// right after app start. CONNECT_ACK already went out without auth; keep
|
|
1211
|
-
// trying to mint (and re-drive on cloud-connect) so the app authenticates
|
|
1212
|
-
// to its backend on its own, instead of staying dead until a full manual
|
|
1213
|
-
// Cloud V2 reconnect re-runs the whole handshake.
|
|
1214
|
-
this.deliverInitialMiniappAuth(packageName, authPromise, 0);
|
|
1215
|
-
}
|
|
1225
|
+
this.deliverInitialMiniappAuth(packageName, authPromise, 0);
|
|
1216
1226
|
this.sendCloudStatusToMiniapp(packageName);
|
|
1217
1227
|
// Handshake complete — unblock any launcher.waitForConnect() callers.
|
|
1218
1228
|
this.handshookApps.add(packageName);
|
|
@@ -3113,13 +3123,22 @@ class LocalMiniappRuntime {
|
|
|
3113
3123
|
}
|
|
3114
3124
|
const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : "";
|
|
3115
3125
|
const token = typeof payload.token === "string" ? payload.token : "";
|
|
3116
|
-
const videoSource = payload.videoSource;
|
|
3117
|
-
const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : "";
|
|
3118
3126
|
const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined;
|
|
3119
|
-
if (!meetingUrl || !token
|
|
3127
|
+
if (!meetingUrl || !token) {
|
|
3120
3128
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3121
3129
|
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
3122
|
-
message: "meetingUrl
|
|
3130
|
+
message: "meetingUrl and token are required",
|
|
3131
|
+
});
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
let videoSource;
|
|
3135
|
+
try {
|
|
3136
|
+
videoSource = parseAcsVideoSource(payload.videoSource);
|
|
3137
|
+
}
|
|
3138
|
+
catch (error) {
|
|
3139
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3140
|
+
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
3141
|
+
message: error instanceof Error ? error.message : "Invalid meeting video source",
|
|
3123
3142
|
});
|
|
3124
3143
|
return;
|
|
3125
3144
|
}
|
|
@@ -3135,10 +3154,48 @@ class LocalMiniappRuntime {
|
|
|
3135
3154
|
});
|
|
3136
3155
|
return;
|
|
3137
3156
|
}
|
|
3157
|
+
// SoftAP is a sequence, not a single call: the hotspot and the scoped network have to exist
|
|
3158
|
+
// before the ACS join binds a listener, and the glasses can only be told where to publish
|
|
3159
|
+
// after that. The miniapp asks for the transport and the host owns the ordering.
|
|
3160
|
+
if (videoSource.type === "softap") {
|
|
3161
|
+
// The miniapp's `requestId` is the only identifier the two logs share until the attempt
|
|
3162
|
+
// exists, so it is what joins the miniapp's own join line to the host's.
|
|
3163
|
+
softapTrace("meeting_join_request", {
|
|
3164
|
+
packageName,
|
|
3165
|
+
requestId: requestId ?? "none",
|
|
3166
|
+
transport: videoSource.type,
|
|
3167
|
+
hasDisplayName: Boolean(displayName),
|
|
3168
|
+
video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
|
|
3169
|
+
});
|
|
3170
|
+
const startedAt = Date.now();
|
|
3171
|
+
try {
|
|
3172
|
+
const state = await this.joinSoftapMeeting(packageName, { meetingUrl, token, displayName, video });
|
|
3173
|
+
softapTrace("meeting_join_result", {
|
|
3174
|
+
packageName,
|
|
3175
|
+
requestId: requestId ?? "none",
|
|
3176
|
+
state: state.state,
|
|
3177
|
+
micTransport: state.micTransport ?? "unknown",
|
|
3178
|
+
durationMs: Date.now() - startedAt,
|
|
3179
|
+
});
|
|
3180
|
+
this.sendResult(packageName, requestId, true, state);
|
|
3181
|
+
}
|
|
3182
|
+
catch (error) {
|
|
3183
|
+
softapTraceFailure("meeting_join_result", {
|
|
3184
|
+
packageName,
|
|
3185
|
+
requestId: requestId ?? "none",
|
|
3186
|
+
step: error instanceof SoftapCallError ? error.step : "unknown",
|
|
3187
|
+
code: error instanceof SoftapCallError ? error.code : "INTERNAL",
|
|
3188
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3189
|
+
durationMs: Date.now() - startedAt,
|
|
3190
|
+
});
|
|
3191
|
+
throw error;
|
|
3192
|
+
}
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3138
3195
|
const state = await acsMeetingService.join(packageName, {
|
|
3139
3196
|
meetingUrl,
|
|
3140
3197
|
token,
|
|
3141
|
-
|
|
3198
|
+
videoSource,
|
|
3142
3199
|
displayName,
|
|
3143
3200
|
...(video ? { video } : {}),
|
|
3144
3201
|
});
|
|
@@ -3151,24 +3208,642 @@ class LocalMiniappRuntime {
|
|
|
3151
3208
|
});
|
|
3152
3209
|
}
|
|
3153
3210
|
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Runs the SoftAP call sequence and returns the meeting state it reached.
|
|
3213
|
+
*
|
|
3214
|
+
* Everything is hung off one [SoftapAttempt] registered before the first `await`, because the
|
|
3215
|
+
* cancellable part of this starts immediately and the transport only exists halfway through.
|
|
3216
|
+
* The attempt is also what `meeting.leave` unwinds: leaving only the ACS call would strand the
|
|
3217
|
+
* glasses publishing into a listener nobody reads, the phone on a hotspot it no longer needs,
|
|
3218
|
+
* and this process pinned to cellular.
|
|
3219
|
+
*/
|
|
3220
|
+
async joinSoftapMeeting(packageName, args) {
|
|
3221
|
+
// Reserve before awaiting retirement: a second Start or Cancel must see this request,
|
|
3222
|
+
// including while it is waiting for its predecessor's native cleanup.
|
|
3223
|
+
const previous = this.softapAttempt;
|
|
3224
|
+
const attempt = this.createSoftapAttempt(packageName);
|
|
3225
|
+
this.softapAttempt = attempt;
|
|
3226
|
+
const body = this.runSoftapAttempt(attempt, previous, args);
|
|
3227
|
+
// Settled, never rejected: this exists so the *next* attempt can tell when this one's native
|
|
3228
|
+
// work has actually stopped, and a rejection here is already handled below.
|
|
3229
|
+
attempt.body = body.then(() => undefined, () => undefined);
|
|
3230
|
+
try {
|
|
3231
|
+
return await body;
|
|
3232
|
+
}
|
|
3233
|
+
catch (error) {
|
|
3234
|
+
softapTraceFailure("softap_attempt_failed", {
|
|
3235
|
+
attempt: attempt.id,
|
|
3236
|
+
step: error instanceof SoftapCallError ? error.step : "unknown",
|
|
3237
|
+
code: error instanceof SoftapCallError ? error.code : "INTERNAL",
|
|
3238
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3239
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
3240
|
+
});
|
|
3241
|
+
// Unwind whatever the attempt reached — including the preflight-owned agent and cellular
|
|
3242
|
+
// pin, which the transport never sees — before the failure surfaces.
|
|
3243
|
+
await this.retireSoftapAttempt({ attempt }).catch(() => undefined);
|
|
3244
|
+
throw error;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
createSoftapAttempt(packageName) {
|
|
3248
|
+
const id = ++this.softapAttemptSeq;
|
|
3249
|
+
let resolveSettled;
|
|
3250
|
+
const settled = new Promise((resolve) => {
|
|
3251
|
+
resolveSettled = resolve;
|
|
3252
|
+
});
|
|
3253
|
+
const attempt = {
|
|
3254
|
+
id,
|
|
3255
|
+
packageName,
|
|
3256
|
+
startedAt: Date.now(),
|
|
3257
|
+
cancelled: false,
|
|
3258
|
+
ownsResources: false,
|
|
3259
|
+
transport: null,
|
|
3260
|
+
progress: {
|
|
3261
|
+
traceId: `preflight-${id}`,
|
|
3262
|
+
phase: "starting",
|
|
3263
|
+
steps: SOFTAP_STEPS.map((step) => ({ step, status: "pending" })),
|
|
3264
|
+
elapsedMs: 0,
|
|
3265
|
+
},
|
|
3266
|
+
scopedLostUnsub: null,
|
|
3267
|
+
body: null,
|
|
3268
|
+
teardown: null,
|
|
3269
|
+
settled,
|
|
3270
|
+
settledDone: false,
|
|
3271
|
+
markSettled: () => {
|
|
3272
|
+
if (attempt.settledDone)
|
|
3273
|
+
return;
|
|
3274
|
+
attempt.settledDone = true;
|
|
3275
|
+
softapTrace("softap_attempt_settled", { attempt: id, attemptAgeMs: Date.now() - attempt.startedAt });
|
|
3276
|
+
resolveSettled();
|
|
3277
|
+
},
|
|
3278
|
+
};
|
|
3279
|
+
softapTrace("softap_attempt_created", { attempt: id, packageName });
|
|
3280
|
+
return attempt;
|
|
3281
|
+
}
|
|
3282
|
+
/**
|
|
3283
|
+
* Throws if this attempt has been cancelled or superseded.
|
|
3284
|
+
*
|
|
3285
|
+
* Called after every `await` in the join. The wearer's Cancel cannot interrupt a native call
|
|
3286
|
+
* that is already in flight, so the next best thing — and the thing that actually makes Cancel
|
|
3287
|
+
* work — is to refuse to take another step once they have asked.
|
|
3288
|
+
*
|
|
3289
|
+
* @param after the await this checkpoint follows. The whole join surfaces as one CANCELLED
|
|
3290
|
+
* error, so without a name in the log there is no way to tell a Cancel during the permission
|
|
3291
|
+
* prompt from one during a 35-second Teams sign-in.
|
|
3292
|
+
*/
|
|
3293
|
+
checkpointSoftapAttempt(attempt, after) {
|
|
3294
|
+
if (attempt.cancelled || this.softapAttempt !== attempt) {
|
|
3295
|
+
softapTraceFailure("softap_attempt_cancelled", {
|
|
3296
|
+
attempt: attempt.id,
|
|
3297
|
+
after,
|
|
3298
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
3299
|
+
// Two different causes with one outcome: the wearer asked to leave, or a second join
|
|
3300
|
+
// took the slot. They need different fixes, so they are not collapsed here.
|
|
3301
|
+
cause: attempt.cancelled ? "retired" : "superseded",
|
|
3302
|
+
current: this.softapAttempt?.id ?? "none",
|
|
3303
|
+
});
|
|
3304
|
+
throw new SoftapCallError("hotspot", "CANCELLED", "The call was cancelled while it was starting");
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
/** Publishes a checklist snapshot, unless this attempt has already been superseded. */
|
|
3308
|
+
emitSoftapProgress(attempt, progress) {
|
|
3309
|
+
if (this.softapAttempt !== attempt || attempt.cancelled) {
|
|
3310
|
+
// A dropped checklist is why a stalled join used to look like a stuck UI: the wearer's last
|
|
3311
|
+
// row was written by an attempt that no longer owned the call, and nothing said so.
|
|
3312
|
+
softapTrace("softap_progress_dropped", {
|
|
3313
|
+
attempt: attempt.id,
|
|
3314
|
+
phase: progress.phase,
|
|
3315
|
+
traceId: progress.traceId,
|
|
3316
|
+
cause: attempt.cancelled ? "retired" : "superseded",
|
|
3317
|
+
current: this.softapAttempt?.id ?? "none",
|
|
3318
|
+
});
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
attempt.progress = progress;
|
|
3322
|
+
const current = acsMeetingService.getState();
|
|
3323
|
+
this.sendToMiniapp(attempt.packageName, {
|
|
3324
|
+
type: MiniappResponseType.MEETING_STATE,
|
|
3325
|
+
...current,
|
|
3326
|
+
// Before the ACS join there is no native state yet, so the phase reads `connecting`: the
|
|
3327
|
+
// join *is* in progress, and `idle` would make the miniapp think the call ended.
|
|
3328
|
+
state: current.state === "idle" ? "connecting" : current.state,
|
|
3329
|
+
softap: progress,
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
/**
|
|
3333
|
+
* Narrates preflight work on the first row while it is still pending.
|
|
3334
|
+
*
|
|
3335
|
+
* Permissions, mobile data, and the ACS sign-in take most of a minute between them and happen
|
|
3336
|
+
* before the transport exists, so without this the wearer watches five pending rows and
|
|
3337
|
+
* concludes the app has hung. No new step and no protocol change: it is the `hotspot` row's
|
|
3338
|
+
* detail, which the connecting screen renders as its one sub-line until step 1 starts.
|
|
3339
|
+
*/
|
|
3340
|
+
narrateSoftapPreflight(attempt, detail) {
|
|
3341
|
+
this.emitSoftapProgress(attempt, {
|
|
3342
|
+
...attempt.progress,
|
|
3343
|
+
elapsedMs: Date.now() - attempt.startedAt,
|
|
3344
|
+
steps: attempt.progress.steps.map((step) => (step.step === "hotspot" ? { ...step, detail } : step)),
|
|
3345
|
+
});
|
|
3346
|
+
}
|
|
3347
|
+
async runSoftapAttempt(attempt, previous, args) {
|
|
3348
|
+
const packageName = attempt.packageName;
|
|
3349
|
+
if (previous) {
|
|
3350
|
+
softapTrace("softap_join_superseding", {
|
|
3351
|
+
previousAttempt: previous.id,
|
|
3352
|
+
previousAgeMs: Date.now() - previous.startedAt,
|
|
3353
|
+
previousPhase: previous.progress.phase,
|
|
3354
|
+
packageName,
|
|
3355
|
+
});
|
|
3356
|
+
await this.retireSoftapAttempt({ attempt: previous }).catch(() => undefined);
|
|
3357
|
+
await awaitCleanupBarrier({
|
|
3358
|
+
settled: previous.settled,
|
|
3359
|
+
settledDone: previous.settledDone,
|
|
3360
|
+
narrate: (detail) => this.narrateSoftapPreflight(attempt, detail),
|
|
3361
|
+
narrateAfterMs: SOFTAP_CLEANUP_NARRATE_AFTER_MS,
|
|
3362
|
+
attempt: attempt.id,
|
|
3363
|
+
waitingFor: `attempt ${previous.id} teardown`,
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
this.checkpointSoftapAttempt(attempt, "cleanup barrier");
|
|
3367
|
+
const cleanupError = this.softapCleanupError;
|
|
3368
|
+
if (cleanupError) {
|
|
3369
|
+
// Report once, allowing a retry after the wearer power-cycles the glasses.
|
|
3370
|
+
this.softapCleanupError = null;
|
|
3371
|
+
softapTraceFailure("softap_join_refused", { packageName, reason: cleanupError });
|
|
3372
|
+
throw new Error(`Previous call cleanup failed: ${cleanupError}. Power-cycle the glasses hotspot and try again.`);
|
|
3373
|
+
}
|
|
3374
|
+
attempt.ownsResources = true;
|
|
3375
|
+
this.narrateSoftapPreflight(attempt, "Checking the Nearby devices permission…");
|
|
3376
|
+
const permissionStartedAt = Date.now();
|
|
3377
|
+
if (!(await permissions.check(PermissionFeatures.LOCAL_WIFI))) {
|
|
3378
|
+
this.checkpointSoftapAttempt(attempt, "permission check");
|
|
3379
|
+
softapTrace("softap_permission_prompt", { attempt: attempt.id, permission: "LOCAL_WIFI" });
|
|
3380
|
+
const granted = await permissions.request(PermissionFeatures.LOCAL_WIFI);
|
|
3381
|
+
this.checkpointSoftapAttempt(attempt, "permission prompt");
|
|
3382
|
+
softapTrace("softap_permission_result", {
|
|
3383
|
+
attempt: attempt.id,
|
|
3384
|
+
granted,
|
|
3385
|
+
// The prompt is modal, so this is the wearer's own reaction time and the single largest
|
|
3386
|
+
// unexplained gap in a first-run join.
|
|
3387
|
+
durationMs: Date.now() - permissionStartedAt,
|
|
3388
|
+
});
|
|
3389
|
+
if (!granted) {
|
|
3390
|
+
throw new Error("Nearby devices permission is required to join the glasses hotspot");
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
this.checkpointSoftapAttempt(attempt, "permissions");
|
|
3394
|
+
const transport = new SoftapCallTransport(createSoftapCallDeps({
|
|
3395
|
+
packageName,
|
|
3396
|
+
meetingUrl: args.meetingUrl,
|
|
3397
|
+
token: args.token,
|
|
3398
|
+
displayName: args.displayName,
|
|
3399
|
+
awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
|
|
3400
|
+
subsystems: {
|
|
3401
|
+
setHotspotState: async (enabled) => {
|
|
3402
|
+
const status = await BluetoothSdk.setHotspotState(enabled);
|
|
3403
|
+
if (status.state === "enabled") {
|
|
3404
|
+
return { state: status.state, ssid: status.ssid, password: status.password };
|
|
3405
|
+
}
|
|
3406
|
+
return { state: status.state };
|
|
3407
|
+
},
|
|
3408
|
+
joinScopedNetwork: (ssid, passphrase) => acsMeetingService.joinScopedNetwork(ssid, passphrase),
|
|
3409
|
+
leaveScopedNetwork: () => acsMeetingService.leaveScopedNetwork(),
|
|
3410
|
+
probeGateway: async () => {
|
|
3411
|
+
const verdict = await acsMeetingService.probeScopedGateway();
|
|
3412
|
+
// No native probe means no verdict, and the orchestrator must not read that as "down".
|
|
3413
|
+
return verdict ?? { reachable: true, detail: "probe unsupported on this host" };
|
|
3414
|
+
},
|
|
3415
|
+
awaitValidatedDefaultNetwork: () => acsMeetingService.awaitValidatedDefaultNetwork(),
|
|
3416
|
+
onGlassesStreamStatus: (listener) => {
|
|
3417
|
+
const sub = BluetoothSdk.addListener("stream_status", (event) => {
|
|
3418
|
+
listener({
|
|
3419
|
+
status: event.status,
|
|
3420
|
+
streamId: event.streamId,
|
|
3421
|
+
reason: "reason" in event && typeof event.reason === "string" ? event.reason : undefined,
|
|
3422
|
+
error: "error" in event && typeof event.error === "string" ? event.error : undefined,
|
|
3423
|
+
});
|
|
3424
|
+
});
|
|
3425
|
+
return () => sub.remove();
|
|
3426
|
+
},
|
|
3427
|
+
joinMeeting: (pkg, options) => acsMeetingService.join(pkg, {
|
|
3428
|
+
meetingUrl: options.meetingUrl,
|
|
3429
|
+
token: options.token,
|
|
3430
|
+
videoSource: options.videoSource,
|
|
3431
|
+
displayName: options.displayName,
|
|
3432
|
+
...(args.video ? { video: args.video } : {}),
|
|
3433
|
+
}),
|
|
3434
|
+
leaveMeeting: (pkg) => acsMeetingService.leave(pkg),
|
|
3435
|
+
endMeeting: async (pkg) => {
|
|
3436
|
+
await acsMeetingService.endForEveryone(pkg);
|
|
3437
|
+
},
|
|
3438
|
+
ingestUrl: () => acsMeetingService.softApIngestUrl(),
|
|
3439
|
+
glassesLc3Uplink: () => acsMeetingService.glassesLc3UplinkActive(),
|
|
3440
|
+
startPublishing: (pkg, options) => phoneStreamCoordinator.startUnmanaged(pkg, options),
|
|
3441
|
+
stopPublishing: (pkg) => phoneStreamCoordinator.stop(pkg),
|
|
3442
|
+
},
|
|
3443
|
+
}));
|
|
3444
|
+
attempt.transport = transport;
|
|
3445
|
+
// A hotspot that vanishes mid-call is a real failure and a normal teardown is not, so both
|
|
3446
|
+
// guards are checked: the attempt must still be the current one (a stale event from a finished
|
|
3447
|
+
// call must never touch the next one) and it must not already be terminating. The unsubscribe
|
|
3448
|
+
// lives on the attempt, so retiring attempt N cannot clear attempt N+1's listener. Teardown
|
|
3449
|
+
// itself is left to the miniapp's terminal path, which calls `meeting.leave` — one owner.
|
|
3450
|
+
attempt.scopedLostUnsub = acsMeetingService.onScopedNetworkLost((error) => {
|
|
3451
|
+
if (this.softapAttempt !== attempt || attempt.cancelled) {
|
|
3452
|
+
console.log("[LocalMiniappRuntime] scoped loss for a superseded SoftAP call; ignoring", error);
|
|
3453
|
+
softapTrace("softap_scoped_loss_dropped", {
|
|
3454
|
+
attempt: attempt.id,
|
|
3455
|
+
code: error.code,
|
|
3456
|
+
cause: attempt.cancelled ? "retired" : "superseded",
|
|
3457
|
+
current: this.softapAttempt?.id ?? "none",
|
|
3458
|
+
});
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
if (transport.isTerminating()) {
|
|
3462
|
+
console.log("[LocalMiniappRuntime] scoped loss during teardown; expected", error);
|
|
3463
|
+
softapTrace("softap_scoped_loss_dropped", { attempt: attempt.id, code: error.code, cause: "terminating" });
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
console.warn("[LocalMiniappRuntime] SoftAP hotspot lost mid-call", error);
|
|
3467
|
+
softapTraceFailure("softap_scoped_loss_reported", {
|
|
3468
|
+
attempt: attempt.id,
|
|
3469
|
+
code: error.code,
|
|
3470
|
+
reason: error.message,
|
|
3471
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
3472
|
+
});
|
|
3473
|
+
const current = acsMeetingService.getState();
|
|
3474
|
+
this.sendToMiniapp(packageName, {
|
|
3475
|
+
type: MiniappResponseType.MEETING_STATE,
|
|
3476
|
+
...current,
|
|
3477
|
+
state: "error",
|
|
3478
|
+
error: `SOFTAP_NETWORK_LOST: ${error.message}`,
|
|
3479
|
+
softap: transport.progress(),
|
|
3480
|
+
});
|
|
3481
|
+
});
|
|
3482
|
+
// Sign in on the phone's current internet *before* the glasses hotspot takes DNS. On device,
|
|
3483
|
+
// createCallAgent after the scoped join timed out at 20s and only finished once SoftAP was
|
|
3484
|
+
// released. It also waits for validated mobile data, which is why the wearer is told one
|
|
3485
|
+
// honest thing about the whole window rather than nothing at all.
|
|
3486
|
+
this.narrateSoftapPreflight(attempt, "Signing in to Teams…");
|
|
3487
|
+
const prepareStartedAt = Date.now();
|
|
3488
|
+
softapTrace("softap_prepare_agent_begin", { attempt: attempt.id });
|
|
3489
|
+
try {
|
|
3490
|
+
await acsMeetingService.prepareAgent({ token: args.token, displayName: args.displayName });
|
|
3491
|
+
}
|
|
3492
|
+
catch (error) {
|
|
3493
|
+
softapTraceFailure("softap_prepare_agent_failed", {
|
|
3494
|
+
attempt: attempt.id,
|
|
3495
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3496
|
+
durationMs: Date.now() - prepareStartedAt,
|
|
3497
|
+
});
|
|
3498
|
+
throw error;
|
|
3499
|
+
}
|
|
3500
|
+
softapTrace("softap_prepare_agent_done", { attempt: attempt.id, durationMs: Date.now() - prepareStartedAt });
|
|
3501
|
+
this.checkpointSoftapAttempt(attempt, "prepareAgent");
|
|
3502
|
+
await transport.start({
|
|
3503
|
+
// What the wearer has been reading for the last minute, so the first snapshot does not
|
|
3504
|
+
// blank it.
|
|
3505
|
+
initialSteps: attempt.progress.steps,
|
|
3506
|
+
// Every transition reaches the miniapp as a meeting-state event carrying the checklist.
|
|
3507
|
+
onProgress: (progress) => this.emitSoftapProgress(attempt, progress),
|
|
3508
|
+
});
|
|
3509
|
+
this.checkpointSoftapAttempt(attempt, "transport.start");
|
|
3510
|
+
const state = acsMeetingService.getState();
|
|
3511
|
+
softapTrace("softap_attempt_live", {
|
|
3512
|
+
attempt: attempt.id,
|
|
3513
|
+
state: state.state,
|
|
3514
|
+
micTransport: state.micTransport ?? "unknown",
|
|
3515
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
3516
|
+
});
|
|
3517
|
+
return state;
|
|
3518
|
+
}
|
|
3519
|
+
/**
|
|
3520
|
+
* Cancels a SoftAP attempt and does not return until everything it owns has been released.
|
|
3521
|
+
*
|
|
3522
|
+
* This is the barrier the whole design rests on: "leave returned" has to mean "nothing from that
|
|
3523
|
+
* call is still in flight", or the next Start races the previous call's cleanup through the same
|
|
3524
|
+
* hotspot. There is deliberately no deadline — a native call that never returns holds the next
|
|
3525
|
+
* call back rather than letting it race — and a watchdog only says so in the log.
|
|
3526
|
+
*
|
|
3527
|
+
* Safe to call when there is no attempt, and safe to call twice: the teardown is single-flight
|
|
3528
|
+
* per attempt, so a leave and the join's own failure path share one unwind.
|
|
3529
|
+
*/
|
|
3530
|
+
async retireSoftapAttempt(options = {}) {
|
|
3531
|
+
const attempt = options.attempt ?? this.softapAttempt;
|
|
3532
|
+
if (!attempt) {
|
|
3533
|
+
// Not an error — every terminal path calls this without checking — but a Leave that found
|
|
3534
|
+
// nothing to retire and a Leave that tore a call down are very different log entries.
|
|
3535
|
+
softapTrace("softap_retire_noop", { mode: options.mode ?? "leave" });
|
|
3536
|
+
return;
|
|
3537
|
+
}
|
|
3538
|
+
attempt.cancelled = true;
|
|
3539
|
+
// Keep the retiring attempt discoverable until both its join and cleanup settle.
|
|
3540
|
+
// A newer reservation replaces it immediately, but must still wait on its barrier.
|
|
3541
|
+
softapTrace("softap_retire_begin", {
|
|
3542
|
+
attempt: attempt.id,
|
|
3543
|
+
mode: options.mode ?? "leave",
|
|
3544
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
3545
|
+
phase: attempt.progress.phase,
|
|
3546
|
+
// Second and later callers join the first one's teardown rather than running their own, and
|
|
3547
|
+
// that sharing is what makes a leave racing a failed join safe.
|
|
3548
|
+
teardownAlreadyRunning: Boolean(attempt.teardown),
|
|
3549
|
+
});
|
|
3550
|
+
if (!attempt.teardown) {
|
|
3551
|
+
attempt.teardown = this.teardownSoftapAttempt(attempt, options.mode);
|
|
3552
|
+
// The barrier closes over the join body as well as the teardown, and opens only when both
|
|
3553
|
+
// have stopped. A watchdog reports a cleanup that is taking too long; it never declares one
|
|
3554
|
+
// finished, because "probably done by now" is exactly the assumption that broke restarts.
|
|
3555
|
+
const stallWatchdog = setTimeout(() => {
|
|
3556
|
+
console.warn("[LocalMiniappRuntime] softap_cleanup_stalled", {
|
|
3557
|
+
attempt: attempt.id,
|
|
3558
|
+
afterMs: SOFTAP_CLEANUP_STALL_LOG_MS,
|
|
3559
|
+
});
|
|
3560
|
+
}, SOFTAP_CLEANUP_STALL_LOG_MS);
|
|
3561
|
+
void Promise.allSettled([attempt.teardown, attempt.body ?? Promise.resolve()]).then(() => {
|
|
3562
|
+
clearTimeout(stallWatchdog);
|
|
3563
|
+
attempt.markSettled();
|
|
3564
|
+
if (this.softapAttempt === attempt)
|
|
3565
|
+
this.softapAttempt = null;
|
|
3566
|
+
});
|
|
3567
|
+
}
|
|
3568
|
+
const awaitedFrom = Date.now();
|
|
3569
|
+
try {
|
|
3570
|
+
await attempt.teardown;
|
|
3571
|
+
}
|
|
3572
|
+
catch (error) {
|
|
3573
|
+
// Only a refused End reaches here; the local unwind has completed either way, which is the
|
|
3574
|
+
// distinction the wearer's copy depends on.
|
|
3575
|
+
softapTraceFailure("softap_retire_done", {
|
|
3576
|
+
attempt: attempt.id,
|
|
3577
|
+
waitedMs: Date.now() - awaitedFrom,
|
|
3578
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3579
|
+
});
|
|
3580
|
+
throw error;
|
|
3581
|
+
}
|
|
3582
|
+
softapTrace("softap_retire_done", { attempt: attempt.id, waitedMs: Date.now() - awaitedFrom });
|
|
3583
|
+
}
|
|
3584
|
+
/**
|
|
3585
|
+
* Releases everything one attempt could own, in the reverse of the order it was taken.
|
|
3586
|
+
*
|
|
3587
|
+
* The scoped network and the ACS agent are released unconditionally, not only when the transport
|
|
3588
|
+
* built them: `prepareAgent` signs in and pins this whole process to cellular *before* the
|
|
3589
|
+
* transport exists, so a Cancel during sign-in would otherwise leave both behind. Both releases
|
|
3590
|
+
* are idempotent.
|
|
3591
|
+
*
|
|
3592
|
+
* Failures are collected rather than swallowed. Reporting a clean teardown that did not happen
|
|
3593
|
+
* is what lets the next call start on leaked state.
|
|
3594
|
+
*/
|
|
3595
|
+
async teardownSoftapAttempt(attempt, mode) {
|
|
3596
|
+
// A cancelled reservation waiting behind another call never acquired any shared resources.
|
|
3597
|
+
// Its cleanup must not release the preceding call's native agent or network.
|
|
3598
|
+
if (!attempt.ownsResources)
|
|
3599
|
+
return;
|
|
3600
|
+
// Terminal intent before the first release, so the scoped network we are about to drop cannot
|
|
3601
|
+
// be reported as a hotspot that walked away.
|
|
3602
|
+
acsMeetingService.beginScopedTeardown();
|
|
3603
|
+
const failures = [];
|
|
3604
|
+
let endFailure = null;
|
|
3605
|
+
const teardownStartedAt = Date.now();
|
|
3606
|
+
softapTrace("softap_teardown_begin", {
|
|
3607
|
+
attempt: attempt.id,
|
|
3608
|
+
mode: mode ?? "leave",
|
|
3609
|
+
hasTransport: Boolean(attempt.transport),
|
|
3610
|
+
steps: attempt.transport?.activeSteps().join(",") ?? "",
|
|
3611
|
+
});
|
|
3612
|
+
/**
|
|
3613
|
+
* One line per resource, whether it released or not.
|
|
3614
|
+
*
|
|
3615
|
+
* The releases below are sequential and every one of them can block on a native call, so a
|
|
3616
|
+
* teardown that takes half a minute is only readable if each step says how long it took. A
|
|
3617
|
+
* step that never reports is the one that hung.
|
|
3618
|
+
*/
|
|
3619
|
+
const releaseStep = async (step, release) => {
|
|
3620
|
+
const startedAt = Date.now();
|
|
3621
|
+
try {
|
|
3622
|
+
await release();
|
|
3623
|
+
softapTrace("softap_teardown_step", {
|
|
3624
|
+
attempt: attempt.id,
|
|
3625
|
+
step,
|
|
3626
|
+
outcome: "ok",
|
|
3627
|
+
durationMs: Date.now() - startedAt,
|
|
3628
|
+
});
|
|
3629
|
+
return null;
|
|
3630
|
+
}
|
|
3631
|
+
catch (error) {
|
|
3632
|
+
softapTraceFailure("softap_teardown_step", {
|
|
3633
|
+
attempt: attempt.id,
|
|
3634
|
+
step,
|
|
3635
|
+
outcome: "failed",
|
|
3636
|
+
durationMs: Date.now() - startedAt,
|
|
3637
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3638
|
+
});
|
|
3639
|
+
return error;
|
|
3640
|
+
}
|
|
3641
|
+
};
|
|
3642
|
+
const transport = attempt.transport;
|
|
3643
|
+
if (transport) {
|
|
3644
|
+
// The only thing `stop()` throws is a refused End — the local teardown still completed,
|
|
3645
|
+
// so this is the caller's business, not a reason to block the next call.
|
|
3646
|
+
endFailure = await releaseStep("transport.stop", () => transport.stop(mode ? { mode } : {}));
|
|
3647
|
+
const undoFailures = transport.lastTeardownFailures();
|
|
3648
|
+
if (undoFailures.length > 0)
|
|
3649
|
+
failures.push(`could not release ${undoFailures.join(", ")}`);
|
|
3650
|
+
}
|
|
3651
|
+
const scopedFailure = await releaseStep("leaveScopedNetwork", () => acsMeetingService.leaveScopedNetwork());
|
|
3652
|
+
if (scopedFailure) {
|
|
3653
|
+
failures.push(`leaveScopedNetwork: ${scopedFailure instanceof Error ? scopedFailure.message : String(scopedFailure)}`);
|
|
3654
|
+
}
|
|
3655
|
+
// Stay pinned across this wait. Unpinning onto a leftover glasses AP is what made the next
|
|
3656
|
+
// Start Call's `teams:create` return `Request failed (503)` — Cloudflare, no internet, not a
|
|
3657
|
+
// Teams configuration problem.
|
|
3658
|
+
const defaultNetworkStartedAt = Date.now();
|
|
3659
|
+
try {
|
|
3660
|
+
const network = await acsMeetingService.awaitValidatedDefaultNetwork();
|
|
3661
|
+
softapTrace("softap_teardown_step", {
|
|
3662
|
+
attempt: attempt.id,
|
|
3663
|
+
step: "awaitValidatedDefaultNetwork",
|
|
3664
|
+
outcome: "ok",
|
|
3665
|
+
durationMs: Date.now() - defaultNetworkStartedAt,
|
|
3666
|
+
// Null is "this host cannot tell", which is a different fact from an unusable route, and
|
|
3667
|
+
// the cellular pin is dropped on the strength of this answer.
|
|
3668
|
+
network: network?.detail ?? "unknown",
|
|
3669
|
+
usable: network?.usable ?? "unknown",
|
|
3670
|
+
});
|
|
3671
|
+
}
|
|
3672
|
+
catch (error) {
|
|
3673
|
+
console.warn("[LocalMiniappRuntime] default network after SoftAP teardown is unverified", {
|
|
3674
|
+
attempt: attempt.id,
|
|
3675
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3676
|
+
});
|
|
3677
|
+
softapTraceFailure("softap_teardown_step", {
|
|
3678
|
+
attempt: attempt.id,
|
|
3679
|
+
step: "awaitValidatedDefaultNetwork",
|
|
3680
|
+
outcome: "failed",
|
|
3681
|
+
durationMs: Date.now() - defaultNetworkStartedAt,
|
|
3682
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3683
|
+
});
|
|
3684
|
+
}
|
|
3685
|
+
const acsLeaveStartedAt = Date.now();
|
|
3686
|
+
try {
|
|
3687
|
+
const outcome = await acsMeetingService.leaveAndAwait(attempt.packageName);
|
|
3688
|
+
softapTrace("softap_teardown_step", {
|
|
3689
|
+
attempt: attempt.id,
|
|
3690
|
+
step: "acsLeaveAndAwait",
|
|
3691
|
+
// `completed=false` is not a failure and is not recorded as one: the build simply cannot
|
|
3692
|
+
// confirm its own cleanup, so the next call's barrier is the only guarantee left.
|
|
3693
|
+
outcome: outcome.completed ? "ok" : "unconfirmed",
|
|
3694
|
+
reason: outcome.reason ?? "",
|
|
3695
|
+
durationMs: Date.now() - acsLeaveStartedAt,
|
|
3696
|
+
});
|
|
3697
|
+
if (!outcome.completed) {
|
|
3698
|
+
console.warn("[LocalMiniappRuntime] ACS cleanup completion is unverified on this build", {
|
|
3699
|
+
attempt: attempt.id,
|
|
3700
|
+
reason: outcome.reason,
|
|
3701
|
+
});
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
catch (error) {
|
|
3705
|
+
softapTraceFailure("softap_teardown_step", {
|
|
3706
|
+
attempt: attempt.id,
|
|
3707
|
+
step: "acsLeaveAndAwait",
|
|
3708
|
+
outcome: "failed",
|
|
3709
|
+
durationMs: Date.now() - acsLeaveStartedAt,
|
|
3710
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3711
|
+
});
|
|
3712
|
+
failures.push(`ACS leave: ${error instanceof Error ? error.message : String(error)}`);
|
|
3713
|
+
}
|
|
3714
|
+
const hadScopedLostListener = Boolean(attempt.scopedLostUnsub);
|
|
3715
|
+
attempt.scopedLostUnsub?.();
|
|
3716
|
+
attempt.scopedLostUnsub = null;
|
|
3717
|
+
softapTrace("softap_teardown_step", {
|
|
3718
|
+
attempt: attempt.id,
|
|
3719
|
+
step: "scopedLostListener",
|
|
3720
|
+
outcome: hadScopedLostListener ? "ok" : "not taken",
|
|
3721
|
+
});
|
|
3722
|
+
if (failures.length > 0) {
|
|
3723
|
+
this.softapCleanupError = failures.join("; ");
|
|
3724
|
+
console.warn("[LocalMiniappRuntime] softap_cleanup_failed", { attempt: attempt.id, failures });
|
|
3725
|
+
}
|
|
3726
|
+
// The recorded failures are what the *next* join refuses on, so they are logged as the list
|
|
3727
|
+
// that will be quoted back to the wearer rather than only as the individual step failures.
|
|
3728
|
+
softapTrace("softap_teardown_done", {
|
|
3729
|
+
attempt: attempt.id,
|
|
3730
|
+
durationMs: Date.now() - teardownStartedAt,
|
|
3731
|
+
refusesNextCall: failures.length > 0,
|
|
3732
|
+
failures: failures.join("; "),
|
|
3733
|
+
endRefused: Boolean(endFailure),
|
|
3734
|
+
});
|
|
3735
|
+
if (endFailure)
|
|
3736
|
+
throw endFailure;
|
|
3737
|
+
}
|
|
3154
3738
|
async handleMeetingLeave(packageName, requestId) {
|
|
3739
|
+
const startedAt = Date.now();
|
|
3740
|
+
softapTrace("meeting_leave_request", {
|
|
3741
|
+
packageName,
|
|
3742
|
+
requestId: requestId ?? "none",
|
|
3743
|
+
attempt: this.softapAttempt?.id ?? "none",
|
|
3744
|
+
});
|
|
3155
3745
|
try {
|
|
3746
|
+
// A SoftAP call owns the hotspot, the scoped network, and the cellular pin as well as the
|
|
3747
|
+
// ACS call, and retiring the attempt releases all of them. Returning before that finishes is
|
|
3748
|
+
// what let the next Start race this teardown.
|
|
3749
|
+
if (this.softapAttempt) {
|
|
3750
|
+
await this.retireSoftapAttempt();
|
|
3751
|
+
softapTrace("meeting_leave_result", {
|
|
3752
|
+
packageName,
|
|
3753
|
+
requestId: requestId ?? "none",
|
|
3754
|
+
path: "softap",
|
|
3755
|
+
durationMs: Date.now() - startedAt,
|
|
3756
|
+
});
|
|
3757
|
+
this.sendResult(packageName, requestId, true);
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3156
3760
|
await acsMeetingService.leave(packageName);
|
|
3761
|
+
softapTrace("meeting_leave_result", {
|
|
3762
|
+
packageName,
|
|
3763
|
+
requestId: requestId ?? "none",
|
|
3764
|
+
path: "acs",
|
|
3765
|
+
durationMs: Date.now() - startedAt,
|
|
3766
|
+
});
|
|
3157
3767
|
this.sendResult(packageName, requestId, true);
|
|
3158
3768
|
}
|
|
3159
3769
|
catch (err) {
|
|
3770
|
+
softapTraceFailure("meeting_leave_result", {
|
|
3771
|
+
packageName,
|
|
3772
|
+
requestId: requestId ?? "none",
|
|
3773
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
3774
|
+
durationMs: Date.now() - startedAt,
|
|
3775
|
+
});
|
|
3160
3776
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3161
3777
|
code: MiniappErrorCode.INTERNAL,
|
|
3162
3778
|
message: err instanceof Error ? err.message : "ACS meeting leave failed",
|
|
3163
3779
|
});
|
|
3164
3780
|
}
|
|
3165
3781
|
}
|
|
3782
|
+
/**
|
|
3783
|
+
* End the meeting for everyone, then tear this device down.
|
|
3784
|
+
*
|
|
3785
|
+
* A rejection here always means "the others may still be in the meeting", never "you are still in
|
|
3786
|
+
* it": both paths below complete local teardown before the error surfaces.
|
|
3787
|
+
*/
|
|
3788
|
+
async handleMeetingEnd(packageName, requestId) {
|
|
3789
|
+
const startedAt = Date.now();
|
|
3790
|
+
const softap = Boolean(this.softapAttempt);
|
|
3791
|
+
softapTrace("meeting_end_request", {
|
|
3792
|
+
packageName,
|
|
3793
|
+
requestId: requestId ?? "none",
|
|
3794
|
+
attempt: this.softapAttempt?.id ?? "none",
|
|
3795
|
+
});
|
|
3796
|
+
try {
|
|
3797
|
+
if (this.softapAttempt) {
|
|
3798
|
+
await this.retireSoftapAttempt({ mode: "end" });
|
|
3799
|
+
}
|
|
3800
|
+
else {
|
|
3801
|
+
await acsMeetingService.endForEveryone(packageName);
|
|
3802
|
+
}
|
|
3803
|
+
softapTrace("meeting_end_result", {
|
|
3804
|
+
packageName,
|
|
3805
|
+
requestId: requestId ?? "none",
|
|
3806
|
+
path: softap ? "softap" : "acs",
|
|
3807
|
+
durationMs: Date.now() - startedAt,
|
|
3808
|
+
});
|
|
3809
|
+
this.sendResult(packageName, requestId, true);
|
|
3810
|
+
}
|
|
3811
|
+
catch (err) {
|
|
3812
|
+
// A rejection here always means the others may still be in the meeting, never that this
|
|
3813
|
+
// device is — the log has to be readable that way round or it accuses the wrong side.
|
|
3814
|
+
softapTraceFailure("meeting_end_result", {
|
|
3815
|
+
packageName,
|
|
3816
|
+
requestId: requestId ?? "none",
|
|
3817
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
3818
|
+
durationMs: Date.now() - startedAt,
|
|
3819
|
+
});
|
|
3820
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3821
|
+
code: MiniappErrorCode.INTERNAL,
|
|
3822
|
+
message: err instanceof Error ? err.message : "Could not end the meeting for everyone",
|
|
3823
|
+
});
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3166
3826
|
async handleMeetingSetMuted(packageName, payload, requestId) {
|
|
3827
|
+
const startedAt = Date.now();
|
|
3167
3828
|
try {
|
|
3168
3829
|
const state = await acsMeetingService.setMuted(packageName, Boolean(payload.muted));
|
|
3830
|
+
softapTrace("meeting_set_muted", {
|
|
3831
|
+
packageName,
|
|
3832
|
+
requestId: requestId ?? "none",
|
|
3833
|
+
requested: Boolean(payload.muted),
|
|
3834
|
+
muted: state.muted,
|
|
3835
|
+
durationMs: Date.now() - startedAt,
|
|
3836
|
+
});
|
|
3169
3837
|
this.sendResult(packageName, requestId, true, state);
|
|
3170
3838
|
}
|
|
3171
3839
|
catch (err) {
|
|
3840
|
+
softapTraceFailure("meeting_set_muted", {
|
|
3841
|
+
packageName,
|
|
3842
|
+
requestId: requestId ?? "none",
|
|
3843
|
+
requested: Boolean(payload.muted),
|
|
3844
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
3845
|
+
durationMs: Date.now() - startedAt,
|
|
3846
|
+
});
|
|
3172
3847
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3173
3848
|
code: MiniappErrorCode.INTERNAL,
|
|
3174
3849
|
message: err instanceof Error ? err.message : "ACS mute failed",
|