@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.
Files changed (69) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/index.d.ts +2 -0
  4. package/build/index.d.ts.map +1 -1
  5. package/build/index.js +1 -0
  6. package/build/index.js.map +1 -1
  7. package/build/services/AcsMeetingService.d.ts +360 -10
  8. package/build/services/AcsMeetingService.d.ts.map +1 -1
  9. package/build/services/AcsMeetingService.js +836 -19
  10. package/build/services/AcsMeetingService.js.map +1 -1
  11. package/build/services/AudioPlaybackService.d.ts +6 -0
  12. package/build/services/AudioPlaybackService.d.ts.map +1 -1
  13. package/build/services/AudioPlaybackService.js +4 -3
  14. package/build/services/AudioPlaybackService.js.map +1 -1
  15. package/build/services/GlassesMicProbe.d.ts +78 -0
  16. package/build/services/GlassesMicProbe.d.ts.map +1 -0
  17. package/build/services/GlassesMicProbe.js +258 -0
  18. package/build/services/GlassesMicProbe.js.map +1 -0
  19. package/build/services/LocalMiniappRuntime.d.ts +83 -0
  20. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  21. package/build/services/LocalMiniappRuntime.js +714 -39
  22. package/build/services/LocalMiniappRuntime.js.map +1 -1
  23. package/build/services/MentraJSLogPipeline.d.ts +39 -0
  24. package/build/services/MentraJSLogPipeline.d.ts.map +1 -1
  25. package/build/services/MentraJSLogPipeline.js +59 -3
  26. package/build/services/MentraJSLogPipeline.js.map +1 -1
  27. package/build/services/MentraJSRouter.d.ts +1 -1
  28. package/build/services/MentraJSRouter.d.ts.map +1 -1
  29. package/build/services/MentraJSRouter.js +2 -2
  30. package/build/services/MentraJSRouter.js.map +1 -1
  31. package/build/services/MicStateCoordinator.d.ts +22 -0
  32. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  33. package/build/services/MicStateCoordinator.js +34 -3
  34. package/build/services/MicStateCoordinator.js.map +1 -1
  35. package/build/services/PhoneStreamCoordinator.d.ts +8 -0
  36. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  37. package/build/services/PhoneStreamCoordinator.js +2 -0
  38. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  39. package/build/services/SoftapCallTransport.d.ts +365 -0
  40. package/build/services/SoftapCallTransport.d.ts.map +1 -0
  41. package/build/services/SoftapCallTransport.js +722 -0
  42. package/build/services/SoftapCallTransport.js.map +1 -0
  43. package/build/services/SoftapCleanupBarrier.d.ts +45 -0
  44. package/build/services/SoftapCleanupBarrier.d.ts.map +1 -0
  45. package/build/services/SoftapCleanupBarrier.js +51 -0
  46. package/build/services/SoftapCleanupBarrier.js.map +1 -0
  47. package/build/utils/pcm16.d.ts +35 -0
  48. package/build/utils/pcm16.d.ts.map +1 -0
  49. package/build/utils/pcm16.js +85 -0
  50. package/build/utils/pcm16.js.map +1 -0
  51. package/build/utils/softapTrace.d.ts +39 -0
  52. package/build/utils/softapTrace.d.ts.map +1 -0
  53. package/build/utils/softapTrace.js +124 -0
  54. package/build/utils/softapTrace.js.map +1 -0
  55. package/package.json +7 -7
  56. package/src/generated/releaseMetadata.ts +5 -5
  57. package/src/index.ts +2 -0
  58. package/src/services/AcsMeetingService.ts +956 -22
  59. package/src/services/AudioPlaybackService.ts +12 -3
  60. package/src/services/GlassesMicProbe.ts +300 -0
  61. package/src/services/LocalMiniappRuntime.ts +775 -42
  62. package/src/services/MentraJSLogPipeline.ts +70 -3
  63. package/src/services/MentraJSRouter.ts +2 -2
  64. package/src/services/MicStateCoordinator.ts +35 -3
  65. package/src/services/PhoneStreamCoordinator.ts +10 -0
  66. package/src/services/SoftapCallTransport.ts +928 -0
  67. package/src/services/SoftapCleanupBarrier.ts +72 -0
  68. package/src/utils/pcm16.ts +93 -0
  69. package/src/utils/softapTrace.ts +133 -0
@@ -0,0 +1,722 @@
1
+ /**
2
+ * @fileoverview Sequences a SoftAP call. Sequencing only — no sockets, no peers, no BLE.
3
+ *
4
+ * The glasses open a hotspot, the phone joins it without giving up its cellular route, and the
5
+ * glasses publish WebRTC straight to a listener on the phone. Cloudflare is not involved at all.
6
+ *
7
+ * The order below is the whole point of this file, and one step in it is load-bearing:
8
+ *
9
+ * hotspot on -> scoped join -> ACS join (binds the listener and arms the raw outputs)
10
+ * -> glasses publish -> WHIP negotiation -> first frame -> LIVE
11
+ *
12
+ * Publishing must come after the ACS join, not before. `LIVE` means a frame reached ACS, so if the
13
+ * glasses start publishing first, decoded video and audio can arrive before the raw outgoing
14
+ * streams exist and the first frames are dropped by whatever happens to be null at the time. That
15
+ * is not a lifecycle anyone designed; making the order explicit is what removes it.
16
+ *
17
+ * Ownership is deliberately narrow. This object owns the *sequence* and nothing else: the meeting
18
+ * session owns the WHIP listener and the peer, `PhoneStreamCoordinator` owns the publisher, and
19
+ * `localNetworkTransport` owns the scoped network. Every step therefore has exactly one owner that
20
+ * can tear it down, which is what makes leaving mid-join safe.
21
+ */
22
+ import { softapTrace, softapTraceFailure, beginSoftapTrace, resetSoftapTrace } from "../utils/softapTrace";
23
+ /** Steps in order. Also the teardown order, reversed. */
24
+ export const SOFTAP_STEPS = ["hotspot", "scopedJoin", "acsJoin", "publish", "live"];
25
+ /** A failure, named by the step that produced it so the UI and the logs agree on the cause. */
26
+ export class SoftapCallError extends Error {
27
+ step;
28
+ code;
29
+ cause;
30
+ constructor(step, code, message, cause) {
31
+ super(message);
32
+ this.step = step;
33
+ this.code = code;
34
+ this.cause = cause;
35
+ this.name = "SoftapCallError";
36
+ }
37
+ }
38
+ /** Gallery sync already learned this: glasses report enabled before the SSID is in the phone scan. */
39
+ export const HOTSPOT_BROADCAST_WAIT_MS = 3_000;
40
+ /**
41
+ * Android's WifiNetworkSpecifier called onUnavailable. The native message lists three causes
42
+ * because the callback does not say which one happened; on the 18:02 Samsung path the SSID was
43
+ * in scan and the join sheet was bypassed — assoc rejected after leaving another AP.
44
+ */
45
+ function isScopedJoinUnavailable(error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ return /SOFTAP_UNAVAILABLE|ScopedNetworkError\$Unavailable|SSID not in scan, Wi-Fi off, or the system join prompt/i.test(message);
48
+ }
49
+ function freshSteps() {
50
+ return SOFTAP_STEPS.map((step) => ({ step, status: "pending" }));
51
+ }
52
+ /** See {@link SoftapCallOptions.initialSteps}. */
53
+ function seededSteps(initial) {
54
+ if (!initial?.length)
55
+ return freshSteps();
56
+ return freshSteps().map((step) => {
57
+ const seed = initial.find((entry) => entry.step === step.step);
58
+ return seed?.detail ? { ...step, detail: seed.detail } : step;
59
+ });
60
+ }
61
+ /** A promise plus the function that settles it. */
62
+ function deferred() {
63
+ let resolve;
64
+ const promise = new Promise((settle) => {
65
+ resolve = settle;
66
+ });
67
+ return { promise, resolve };
68
+ }
69
+ export class SoftapEndNotSupportedError extends Error {
70
+ constructor() {
71
+ super("This host cannot end a meeting for everyone");
72
+ this.name = "SoftapEndNotSupportedError";
73
+ }
74
+ }
75
+ export class SoftapCallTransport {
76
+ deps;
77
+ phase = "idle";
78
+ /**
79
+ * Steps completed and not yet undone, in the order they succeeded. Teardown walks this
80
+ * backwards, so a failure halfway through unwinds exactly what was built and nothing else.
81
+ */
82
+ completed = [];
83
+ /**
84
+ * Bumped by every start and stop. A step that resolves after the caller has moved on must not
85
+ * write to the new attempt's state, which is the leave-during-join race.
86
+ */
87
+ generation = 0;
88
+ stopping = null;
89
+ /**
90
+ * The step currently in flight, and a promise that settles only after its body *and* the undo it
91
+ * runs when it finds itself cancelled are both finished.
92
+ *
93
+ * This is what [stop] waits on. A step that resolves late still owns a resource — a hotspot that
94
+ * came up after the wearer left — and its release happens inside the step, out of the teardown's
95
+ * sight. Without this wait, `stop()` resolves while that release is still pending and the next
96
+ * call brings a hotspot up straight into it.
97
+ */
98
+ running = null;
99
+ /** True once [start] has been called at least once, so [stop] can tell "cancelled" from "reusable". */
100
+ startedEver = false;
101
+ /**
102
+ * A [stop] that landed before the sequence ever began.
103
+ *
104
+ * There is nothing to unwind in that case, so the flag is the only thing that can carry the
105
+ * cancellation forward: a `start()` arriving afterwards belongs to the attempt that was just
106
+ * cancelled and must refuse rather than build a call nobody is waiting for.
107
+ */
108
+ cancelledBeforeStart = false;
109
+ /** Failed undos for this attempt, retained across repeated stops. See [lastTeardownFailures]. */
110
+ teardownFailures = [];
111
+ /**
112
+ * Raised the instant a teardown is decided, before any resource is touched.
113
+ *
114
+ * Everything that watches the call for failure — a lost hotspot above all — has to be able to ask
115
+ * "was this supposed to happen?". Without the flag, releasing the scoped network during a
116
+ * successful Leave looks exactly like the glasses walking out of range, and the wearer gets an
117
+ * error screen for a call that ended the way they asked.
118
+ */
119
+ terminating = false;
120
+ /** How this teardown ends the meeting. Read by the `acsJoin` undo. */
121
+ teardownMode = "leave";
122
+ /** Set when `stop({mode: "end"})` could not end for everyone. The caller must not claim it did. */
123
+ endFailure = null;
124
+ hotspot = null;
125
+ ingestUrl = null;
126
+ steps = freshSteps();
127
+ stepStartedAt = new Map();
128
+ startedAt = 0;
129
+ traceId = "";
130
+ onProgress;
131
+ constructor(deps) {
132
+ this.deps = deps;
133
+ }
134
+ currentPhase() {
135
+ return this.phase;
136
+ }
137
+ /**
138
+ * True once a teardown has been decided. Anything that would otherwise report a failure — a lost
139
+ * hotspot, a dropped ACS call — must check this first: after the wearer asks to leave, those are
140
+ * the sound of it working.
141
+ */
142
+ isTerminating() {
143
+ return this.terminating;
144
+ }
145
+ /** What the UI should show right now. Safe to call in any phase. */
146
+ progress() {
147
+ return {
148
+ traceId: this.traceId,
149
+ phase: this.phase,
150
+ steps: this.steps.map((step) => ({ ...step })),
151
+ elapsedMs: this.startedAt ? Date.now() - this.startedAt : 0,
152
+ };
153
+ }
154
+ emitProgress() {
155
+ const listener = this.onProgress;
156
+ if (!listener)
157
+ return;
158
+ try {
159
+ listener(this.progress());
160
+ }
161
+ catch (error) {
162
+ softapTraceFailure("softap_progress_listener_threw", {
163
+ reason: error instanceof Error ? error.message : String(error),
164
+ });
165
+ }
166
+ }
167
+ setStep(step, patch) {
168
+ this.steps = this.steps.map((entry) => (entry.step === step ? { ...entry, ...patch } : entry));
169
+ this.emitProgress();
170
+ }
171
+ /** Narration for a running step: what the phone or the glasses just reported. */
172
+ note(generation, step, detail) {
173
+ if (generation !== this.generation) {
174
+ // A step that is still narrating after the sequence moved on. The detail is dropped rather
175
+ // than written onto the new attempt's checklist, and the drop is said out loud because a
176
+ // step that goes quiet here is usually one that is still holding a native call open.
177
+ softapTrace("softap_step_note_dropped", { step, generation, current: this.generation, detail });
178
+ return;
179
+ }
180
+ softapTrace("softap_step_note", { step, detail });
181
+ this.setStep(step, { detail });
182
+ }
183
+ /** Steps currently built up, oldest first. Empty when nothing needs tearing down. */
184
+ activeSteps() {
185
+ return [...this.completed];
186
+ }
187
+ /** The URL handed to the glasses, for diagnostics. Null outside a live attempt. */
188
+ currentIngestUrl() {
189
+ return this.ingestUrl;
190
+ }
191
+ /**
192
+ * Steps whose undo threw during the last teardown, so the caller can refuse the next call.
193
+ *
194
+ * Teardown deliberately swallows these to keep unwinding — but a hotspot that would not turn off
195
+ * is exactly the state the next call cannot be built on, and starting anyway is what produced
196
+ * "Cannot start glasses hotspot" followed by a scoped join that never found the SSID.
197
+ */
198
+ lastTeardownFailures() {
199
+ // Logged on read rather than only on write, because this is the moment the list turns into a
200
+ // refusal for the next call: a wearer told to power-cycle the hotspot needs the reason to be
201
+ // findable, and the write happened somewhere in the middle of a noisy teardown.
202
+ if (this.teardownFailures.length > 0) {
203
+ softapTraceFailure("softap_teardown_failures_read", { steps: this.teardownFailures.join(",") });
204
+ }
205
+ return [...this.teardownFailures];
206
+ }
207
+ /**
208
+ * Runs the sequence. On any failure the partial sequence is torn down before the error is
209
+ * rethrown, so a failed start never leaves a hotspot up or a publisher running.
210
+ */
211
+ async start(options = {}) {
212
+ if (this.cancelledBeforeStart) {
213
+ // The wearer's Cancel landed before the sequence began, so there is no step to name and no
214
+ // trace id yet. This line is the only evidence that the flag did its job rather than the
215
+ // join having silently never been asked for.
216
+ softapTraceFailure("softap_call_refused", { reason: "cancelled before start" });
217
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP call was cancelled before it started");
218
+ }
219
+ if (this.phase !== "idle" && this.phase !== "failed") {
220
+ softapTraceFailure("softap_call_refused", { reason: "already active", phase: this.phase });
221
+ throw new SoftapCallError("hotspot", "ALREADY_ACTIVE", `A SoftAP call is already ${this.phase}`);
222
+ }
223
+ this.startedEver = true;
224
+ const generation = ++this.generation;
225
+ this.phase = "starting";
226
+ this.terminating = false;
227
+ this.teardownMode = "leave";
228
+ this.endFailure = null;
229
+ this.completed = [];
230
+ this.ingestUrl = null;
231
+ this.hotspot = null;
232
+ this.teardownFailures = [];
233
+ this.steps = seededSteps(options.initialSteps);
234
+ this.stepStartedAt.clear();
235
+ this.startedAt = Date.now();
236
+ this.onProgress = options.onProgress;
237
+ const traceId = beginSoftapTrace(options.traceId);
238
+ this.traceId = traceId;
239
+ softapTrace("softap_call_start", { traceId });
240
+ this.emitProgress();
241
+ try {
242
+ await this.step(generation, "hotspot", "HOTSPOT_FAILED", async (report) => {
243
+ report("Asking the glasses to turn on their hotspot");
244
+ const hotspot = await this.deps.startHotspot(report);
245
+ if (!hotspot.ssid) {
246
+ throw new Error("the glasses reported no hotspot SSID");
247
+ }
248
+ this.hotspot = hotspot;
249
+ // The passphrase never reaches the log; softapTrace redacts it by key, and only the SSID
250
+ // is useful for matching against the phone's Wi-Fi state anyway.
251
+ softapTrace("hotspot_enabled", { ssid: hotspot.ssid });
252
+ report(`Hotspot ${hotspot.ssid} is on; waiting for it to broadcast`);
253
+ await this.deps.waitUntilHotspotJoinable?.(report);
254
+ softapTrace("hotspot_broadcast_wait_done", { ssid: hotspot.ssid });
255
+ report(`Hotspot ${hotspot.ssid}`);
256
+ });
257
+ const hotspot = this.requireHotspot();
258
+ let bindAddress;
259
+ await this.step(generation, "scopedJoin", "SCOPED_JOIN_FAILED", async (report) => {
260
+ report(`Phone joining ${hotspot.ssid}. Turn Wi-Fi on if a panel opens — Teams stays on cellular.`);
261
+ bindAddress = await this.deps.joinScopedNetwork(hotspot.ssid, hotspot.passphrase, report);
262
+ softapTrace("scoped_network_joined", { bindAddress: bindAddress ?? "unknown" });
263
+ if (bindAddress)
264
+ report(`Phone is ${bindAddress} on ${hotspot.ssid}`);
265
+ });
266
+ await this.step(generation, "acsJoin", "ACS_JOIN_FAILED", async (report) => {
267
+ report(bindAddress ? `Opening video receiver on ${bindAddress}, then joining Teams` : "Joining Teams");
268
+ const { ingestUrl } = await this.deps.joinMeeting({
269
+ ssid: hotspot.ssid,
270
+ passphrase: hotspot.passphrase,
271
+ bindAddress,
272
+ }, report);
273
+ if (!ingestUrl) {
274
+ // Without a bound listener there is nowhere for the glasses to publish, and telling them
275
+ // to publish anyway produces a failure several seconds later on the wrong device.
276
+ throw new Error("the meeting reported no ingest URL");
277
+ }
278
+ this.ingestUrl = ingestUrl;
279
+ softapTrace("acs_joined", { ingestUrl });
280
+ report(`Receiver ready at ${ingestUrl}`);
281
+ });
282
+ const ingestUrl = this.requireIngestUrl();
283
+ await this.step(generation, "publish", "PUBLISH_FAILED", async (report) => {
284
+ report("Telling the glasses to start the camera and publish to the phone");
285
+ await this.deps.startPublishing({ ingestUrl, traceId }, report);
286
+ softapTrace("glasses_publishing", { ingestUrl });
287
+ report("Glasses camera is streaming to the phone");
288
+ });
289
+ await this.step(generation, "live", "NO_FIRST_FRAME", async (report) => {
290
+ report("Waiting for the first video frame to reach Teams");
291
+ await this.deps.awaitFirstFrame(report);
292
+ softapTrace("first_frame_in_acs");
293
+ report("Video is live in the meeting");
294
+ });
295
+ if (generation !== this.generation) {
296
+ // Every step succeeded and the call is nevertheless not this transport's any more. The
297
+ // steps released themselves on the way past, so there is nothing to undo — but a join
298
+ // that got all the way to a frame and then vanished is otherwise a log that simply stops.
299
+ softapTraceFailure("softap_call_abandoned_at_live", { generation, current: this.generation });
300
+ return;
301
+ }
302
+ this.phase = "live";
303
+ softapTrace("softap_call_live");
304
+ this.emitProgress();
305
+ }
306
+ catch (error) {
307
+ // Unwind before rethrowing. A caller that sees a rejection is entitled to assume nothing was
308
+ // left running, and a hotspot left up is both a battery cost and a second call's failure.
309
+ await this.stop({ keepProgress: true });
310
+ this.phase = "failed";
311
+ this.emitProgress();
312
+ throw error;
313
+ }
314
+ }
315
+ /**
316
+ * Tears down in exact reverse order, and only what was built.
317
+ *
318
+ * Every step is attempted even if an earlier one throws: a failure to stop the publisher must
319
+ * not leave the hotspot on. Concurrent calls share one teardown rather than racing each other
320
+ * through the same resources, and a second `stop()` after one finished is a no-op — this is the
321
+ * only SoftAP exit, so every terminal path can call it without checking whether another already
322
+ * did.
323
+ *
324
+ * `mode: "end"` swaps the meeting verb and nothing else. If ending for everyone fails, the rest of
325
+ * the teardown still runs and the failure is rethrown at the end, so the caller can tell the
326
+ * wearer they left a meeting that may still be live rather than inventing a clean end.
327
+ */
328
+ async stop(options = {}) {
329
+ // Intent before action, always: a watcher must be able to tell a deliberate teardown from a
330
+ // failure even during the very first await below.
331
+ this.terminating = true;
332
+ if (options.mode)
333
+ this.teardownMode = options.mode;
334
+ if (this.stopping) {
335
+ softapTrace("softap_stop_joined_in_flight", { mode: this.teardownMode });
336
+ return this.stopping;
337
+ }
338
+ const running = this.running;
339
+ if (this.completed.length === 0 && this.phase === "idle" && !running) {
340
+ // Nothing was built, so there is nothing to unwind — but a start() that has not run yet
341
+ // still has to be refused, and a generation bump still has to invalidate anything holding
342
+ // the old one.
343
+ this.generation++;
344
+ if (!this.startedEver)
345
+ this.cancelledBeforeStart = true;
346
+ softapTrace("softap_stop_nothing_built", {
347
+ // The distinction the next `start()` turns on: a transport that was never started refuses
348
+ // outright, one that has already run is reusable.
349
+ cancelledBeforeStart: this.cancelledBeforeStart,
350
+ generation: this.generation,
351
+ });
352
+ return;
353
+ }
354
+ this.generation++;
355
+ this.phase = "stopping";
356
+ this.endFailure = null;
357
+ softapTrace("softap_call_stop", { steps: this.completed.join(","), mode: this.teardownMode });
358
+ this.emitProgress();
359
+ this.stopping = (async () => {
360
+ // The generation bump above has already told the in-flight step to release whatever it
361
+ // produced. Waiting for that release is what makes a resolved `stop()` mean "nothing from
362
+ // this call is still coming". Deliberately unbounded: a native call that never returns must
363
+ // hold the next call back, never let it race this one's cleanup.
364
+ if (running) {
365
+ softapTrace("softap_stop_waiting_for_step", { step: running.step });
366
+ const waitStartedAt = Date.now();
367
+ await running.settled;
368
+ // This wait is unbounded by design, so its duration is the difference between "the leave
369
+ // was slow" and "the leave was held by a native call that had not returned".
370
+ softapTrace("softap_stop_step_settled", { step: running.step, waitedMs: Date.now() - waitStartedAt });
371
+ }
372
+ // The late step may have recorded a failed self-undo while we waited. Preserve it,
373
+ // and any earlier teardown result, until start() explicitly begins a new attempt.
374
+ const failures = [...this.teardownFailures];
375
+ for (const step of [...this.completed].reverse()) {
376
+ const undoStartedAt = Date.now();
377
+ try {
378
+ await this.undo(step);
379
+ softapTrace("softap_step_undone", { step, durationMs: Date.now() - undoStartedAt });
380
+ }
381
+ catch (error) {
382
+ // Recorded, not rethrown: the remaining steps still have to be undone. The caller reads
383
+ // them back through [lastTeardownFailures] and refuses the next call, because a hotspot
384
+ // that would not turn off is exactly the state the next call cannot build on.
385
+ failures.push(step);
386
+ softapTraceFailure("softap_step_undo_failed", {
387
+ step,
388
+ durationMs: Date.now() - undoStartedAt,
389
+ reason: error instanceof Error ? error.message : String(error),
390
+ });
391
+ }
392
+ }
393
+ this.completed = [];
394
+ this.hotspot = null;
395
+ this.ingestUrl = null;
396
+ this.phase = "idle";
397
+ this.teardownFailures = failures;
398
+ softapTrace("softap_call_stopped", { undoFailures: failures.join(",") });
399
+ resetSoftapTrace();
400
+ // A failed start keeps its checklist so the UI can show which step broke; a deliberate
401
+ // leave wipes it, because there is nothing left to explain.
402
+ if (!options.keepProgress) {
403
+ this.steps = freshSteps();
404
+ this.emitProgress();
405
+ }
406
+ })();
407
+ try {
408
+ await this.stopping;
409
+ }
410
+ finally {
411
+ this.stopping = null;
412
+ }
413
+ // Rethrown last, after every resource is released. An End that could not terminate the meeting
414
+ // has still taken this device out; only the claim about the others is wrong.
415
+ const endFailure = this.endFailure;
416
+ this.endFailure = null;
417
+ if (endFailure)
418
+ throw endFailure;
419
+ }
420
+ async undo(step) {
421
+ switch (step) {
422
+ // `live` is an observation, not a resource — there is nothing to release.
423
+ case "live":
424
+ return;
425
+ case "publish":
426
+ return this.deps.stopPublishing();
427
+ case "acsJoin":
428
+ return this.leaveOrEndMeeting();
429
+ case "scopedJoin":
430
+ return this.deps.leaveScopedNetwork();
431
+ case "hotspot":
432
+ return this.deps.stopHotspot();
433
+ }
434
+ }
435
+ /**
436
+ * The one step End changes.
437
+ *
438
+ * A failed End falls back to leaving, so the wearer is out either way, and the original failure is
439
+ * kept for [stop] to rethrow. Recording it rather than throwing here is what keeps the hotspot
440
+ * teardown — the steps after this one — unconditional.
441
+ */
442
+ async leaveOrEndMeeting() {
443
+ if (this.teardownMode !== "end")
444
+ return this.deps.leaveMeeting();
445
+ const endMeeting = this.deps.endMeeting;
446
+ if (!endMeeting) {
447
+ this.endFailure = new SoftapEndNotSupportedError();
448
+ return this.deps.leaveMeeting();
449
+ }
450
+ try {
451
+ await endMeeting();
452
+ softapTrace("softap_meeting_ended_for_everyone");
453
+ }
454
+ catch (error) {
455
+ this.endFailure = error;
456
+ softapTraceFailure("softap_end_for_everyone_failed", {
457
+ reason: error instanceof Error ? error.message : String(error),
458
+ });
459
+ // Native ends the local call even when the hang-up is refused, so this is a belt-and-braces
460
+ // leave rather than a second teardown: it must not resurrect the failure it is covering for.
461
+ await this.deps.leaveMeeting().catch(() => undefined);
462
+ }
463
+ }
464
+ /**
465
+ * Runs one step, records it as undoable, and maps any throw to a [SoftapCallError] naming the
466
+ * step. The generation check is what makes leaving mid-step safe: a step that resolves after the
467
+ * caller gave up is not recorded, so teardown does not try to undo it twice.
468
+ */
469
+ async step(generation, step, code, run) {
470
+ if (generation !== this.generation) {
471
+ // The sequence stopped between two steps. Named here because the caller only ever sees one
472
+ // CANCELLED error, and which step it never reached is the thing worth knowing.
473
+ softapTraceFailure("softap_step_skipped_after_cancel", { step, generation, current: this.generation });
474
+ throw new SoftapCallError(step, "CANCELLED", `SoftAP call was cancelled before ${step}`);
475
+ }
476
+ // Published before the first await so a `stop()` on the very next tick can see it. Settled in
477
+ // the `finally`, after any self-undo, so waiting on it means the step owns nothing any more.
478
+ const settle = deferred();
479
+ this.running = { step, settled: settle.promise };
480
+ try {
481
+ await this.runStep(generation, step, code, run);
482
+ }
483
+ finally {
484
+ if (this.running?.settled === settle.promise)
485
+ this.running = null;
486
+ settle.resolve();
487
+ }
488
+ }
489
+ /** The step body itself. Split out so [step] can publish and settle {@link running} around it. */
490
+ async runStep(generation, step, code, run) {
491
+ softapTrace("softap_step_begin", { step });
492
+ const startedAt = Date.now();
493
+ this.stepStartedAt.set(step, startedAt);
494
+ this.setStep(step, { status: "running", error: undefined });
495
+ const report = (detail) => this.note(generation, step, detail);
496
+ try {
497
+ await run(report);
498
+ }
499
+ catch (error) {
500
+ const reason = error instanceof Error ? error.message : String(error);
501
+ softapTraceFailure("softap_step_failed", { step, code, reason });
502
+ if (generation === this.generation) {
503
+ this.setStep(step, { status: "failed", error: reason, durationMs: Date.now() - startedAt });
504
+ }
505
+ throw new SoftapCallError(step, code, error instanceof Error ? error.message : `${step} failed`, error);
506
+ }
507
+ if (generation !== this.generation) {
508
+ // The step succeeded after the caller gave up. Release it here rather than recording it for
509
+ // the teardown to find: that teardown may already have walked past this step, or finished
510
+ // altogether, in which case nothing else ever would. This is the leak the generation guard
511
+ // exists to close — a meeting joined a few milliseconds after the user left.
512
+ softapTrace("softap_step_completed_after_cancel", { step });
513
+ await this.undoSafely(step);
514
+ throw new SoftapCallError(step, "CANCELLED", `SoftAP call was cancelled during ${step}`);
515
+ }
516
+ this.completed.push(step);
517
+ softapTrace("softap_step_done", { step, durationMs: Date.now() - startedAt });
518
+ this.setStep(step, { status: "done", durationMs: Date.now() - startedAt });
519
+ }
520
+ /** Undo that reports rather than throws, for the cancellation path where there is no caller. */
521
+ async undoSafely(step) {
522
+ try {
523
+ await this.undo(step);
524
+ }
525
+ catch (error) {
526
+ if (!this.teardownFailures.includes(step))
527
+ this.teardownFailures.push(step);
528
+ softapTraceFailure("softap_step_undo_failed", {
529
+ step,
530
+ reason: error instanceof Error ? error.message : String(error),
531
+ });
532
+ }
533
+ }
534
+ requireHotspot() {
535
+ const hotspot = this.hotspot;
536
+ if (!hotspot)
537
+ throw new SoftapCallError("hotspot", "HOTSPOT_FAILED", "no hotspot credentials");
538
+ return hotspot;
539
+ }
540
+ requireIngestUrl() {
541
+ const url = this.ingestUrl;
542
+ if (!url)
543
+ throw new SoftapCallError("acsJoin", "ACS_JOIN_FAILED", "no ingest URL");
544
+ return url;
545
+ }
546
+ }
547
+ /**
548
+ * Binds the sequence to the real subsystems.
549
+ *
550
+ * Kept separate from the class so the ordering above is tested against fakes rather than against
551
+ * BLE and ACS. The only logic here is adapting shapes; anything that needs a decision belongs in
552
+ * the class.
553
+ *
554
+ * @param packageName the miniapp that owns the call
555
+ * @param meeting the meeting to join, and how to observe its media health
556
+ */
557
+ export function createSoftapCallDeps(args) {
558
+ const { packageName, subsystems } = args;
559
+ const hotspotBroadcastWaitMs = args.hotspotBroadcastWaitMs ?? HOTSPOT_BROADCAST_WAIT_MS;
560
+ return {
561
+ startHotspot: async (report) => {
562
+ const enable = async () => {
563
+ const status = await subsystems.setHotspotState(true);
564
+ if (status.state !== "enabled" || !status.ssid) {
565
+ throw new Error(`the glasses hotspot did not start (state=${status.state})`);
566
+ }
567
+ if (!status.password) {
568
+ throw new Error("the glasses hotspot reported no password");
569
+ }
570
+ report?.(`Glasses report hotspot ${status.ssid} enabled`);
571
+ return { ssid: status.ssid, passphrase: status.password };
572
+ };
573
+ try {
574
+ return await enable();
575
+ }
576
+ catch (error) {
577
+ if (error instanceof Error && /no password/.test(error.message))
578
+ throw error;
579
+ // Cancel-then-start races the previous disable: the glasses report disabled (or no SSID)
580
+ // and the UI said "Couldn't start glasses hotspot" before step 2 ran on a leftover AP.
581
+ softapTraceFailure("hotspot_enable_retry", {
582
+ reason: error instanceof Error ? error.message : String(error),
583
+ });
584
+ report?.("Glasses hotspot did not start; turning it off and trying again");
585
+ await subsystems.setHotspotState(false);
586
+ if (hotspotBroadcastWaitMs > 0) {
587
+ await new Promise(resolve => setTimeout(resolve, Math.min(1_000, hotspotBroadcastWaitMs)));
588
+ }
589
+ return await enable();
590
+ }
591
+ },
592
+ waitUntilHotspotJoinable: async (report) => {
593
+ if (hotspotBroadcastWaitMs <= 0)
594
+ return;
595
+ softapTrace("hotspot_broadcast_wait", { ms: hotspotBroadcastWaitMs });
596
+ report?.(`Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`);
597
+ await new Promise(resolve => setTimeout(resolve, hotspotBroadcastWaitMs));
598
+ },
599
+ stopHotspot: async () => {
600
+ await subsystems.setHotspotState(false);
601
+ },
602
+ joinScopedNetwork: async (ssid, passphrase, report) => {
603
+ const joinOnce = (nextSsid, nextPassphrase) => subsystems.joinScopedNetwork(nextSsid, nextPassphrase);
604
+ let address;
605
+ try {
606
+ address = await joinOnce(ssid, passphrase);
607
+ }
608
+ catch (error) {
609
+ if (!isScopedJoinUnavailable(error))
610
+ throw error;
611
+ // First specifier left the phone's previous Wi-Fi and assoc-rejected the glasses AP.
612
+ // Cycle the AP and join again from an idle STA — the radio is free now.
613
+ softapTraceFailure("scoped_join_unavailable_retry", { ssid });
614
+ report?.("Phone couldn't join; cycling the glasses hotspot and trying again");
615
+ await subsystems.setHotspotState(false);
616
+ const status = await subsystems.setHotspotState(true);
617
+ if (status.state !== "enabled" || !status.ssid || !status.password)
618
+ throw error;
619
+ if (hotspotBroadcastWaitMs > 0) {
620
+ report?.(`Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`);
621
+ await new Promise(resolve => setTimeout(resolve, hotspotBroadcastWaitMs));
622
+ }
623
+ address = await joinOnce(status.ssid, status.password);
624
+ }
625
+ if (subsystems.probeGateway) {
626
+ report?.(`Phone is ${address ?? "on the hotspot"}; checking it can reach the glasses`);
627
+ try {
628
+ const probe = await subsystems.probeGateway();
629
+ softapTrace(probe.reachable ? "gateway_probe_ok" : "gateway_probe_failed", { detail: probe.detail });
630
+ report?.(probe.reachable
631
+ ? `Phone ${address ?? ""} ↔ glasses OK (${probe.detail})`
632
+ : `Phone ${address ?? ""} joined, but cannot reach the glasses: ${probe.detail}`);
633
+ }
634
+ catch (error) {
635
+ softapTraceFailure("gateway_probe_threw", {
636
+ reason: error instanceof Error ? error.message : String(error),
637
+ });
638
+ }
639
+ }
640
+ return address;
641
+ },
642
+ leaveScopedNetwork: () => subsystems.leaveScopedNetwork(),
643
+ joinMeeting: async ({ ssid, passphrase, bindAddress }, report) => {
644
+ // The hotspot join just took this phone off Wi-Fi, so the route Teams needs is whatever
645
+ // Android promoted in its place. Waiting for it to validate is what stopped the ACS join
646
+ // from burning its whole timeout on DNS that could not resolve yet.
647
+ if (subsystems.awaitValidatedDefaultNetwork) {
648
+ report?.("Waiting for this phone's mobile data to take over so Teams can connect");
649
+ try {
650
+ const network = await subsystems.awaitValidatedDefaultNetwork();
651
+ if (network) {
652
+ softapTrace(network.usable ? "default_network_ok" : "default_network_unvalidated", {
653
+ detail: network.detail,
654
+ });
655
+ report?.(network.usable
656
+ ? `Internet is on ${network.detail}`
657
+ : `Internet is not confirmed yet (${network.detail}); joining Teams anyway`);
658
+ }
659
+ }
660
+ catch (error) {
661
+ softapTraceFailure("default_network_check_threw", {
662
+ reason: error instanceof Error ? error.message : String(error),
663
+ });
664
+ }
665
+ }
666
+ report?.("Binding the video receiver and joining Teams over cellular");
667
+ await subsystems.joinMeeting(packageName, {
668
+ meetingUrl: args.meetingUrl,
669
+ token: args.token,
670
+ videoSource: { type: "softap", ssid, passphrase, bindAddress },
671
+ displayName: args.displayName,
672
+ });
673
+ // The listener binds during the join, so the URL only exists now.
674
+ return { ingestUrl: subsystems.ingestUrl() ?? "" };
675
+ },
676
+ leaveMeeting: () => subsystems.leaveMeeting(packageName),
677
+ ...(subsystems.endMeeting ? { endMeeting: () => subsystems.endMeeting(packageName) } : {}),
678
+ startPublishing: async ({ ingestUrl, traceId }, report) => {
679
+ // Narrate the glasses side while the BLE start command is in flight. `initializing` means
680
+ // the glasses accepted the command and are opening the camera; `streaming` means the WHIP
681
+ // offer was answered and ICE connected; anything else is the reason it did not.
682
+ const unsubscribe = subsystems.onGlassesStreamStatus?.((event) => {
683
+ if (event.status === "initializing") {
684
+ report?.("Glasses accepted the command: camera starting, gathering ICE, posting offer to the phone");
685
+ }
686
+ else if (event.status === "streaming") {
687
+ report?.("Glasses are streaming to the phone");
688
+ }
689
+ else if (event.status === "error") {
690
+ report?.(`Glasses reported: ${event.error ?? event.reason ?? "stream error"}`);
691
+ }
692
+ else if (event.status === "reconnecting") {
693
+ report?.(`Glasses reconnecting: ${event.reason ?? ""}`);
694
+ }
695
+ });
696
+ // Decided before the command goes out, never after: the glasses cannot drop an audio track
697
+ // they already negotiated, and two live copies of the wearer's voice in one call is worse
698
+ // than either one alone.
699
+ const lc3Uplink = subsystems.glassesLc3Uplink?.() ?? false;
700
+ softapTrace("publish_audio_decision", { captureAudio: !lc3Uplink, micTransport: lc3Uplink ? "ble-lc3" : "whip" });
701
+ report?.(lc3Uplink
702
+ ? "Publishing video only; the wearer's voice comes over Bluetooth LC3"
703
+ : "Publishing video and the glasses microphone");
704
+ try {
705
+ await subsystems.startPublishing(packageName, {
706
+ streamUrl: ingestUrl,
707
+ // Empty STUN server means host-only: there is no route from the hotspot to a STUN server,
708
+ // so a configured one would add doomed gathering to every call.
709
+ ice: { stun: "" },
710
+ traceId,
711
+ captureAudio: !lc3Uplink,
712
+ });
713
+ }
714
+ finally {
715
+ unsubscribe?.();
716
+ }
717
+ },
718
+ stopPublishing: () => subsystems.stopPublishing(packageName),
719
+ awaitFirstFrame: args.awaitFirstFrame,
720
+ };
721
+ }
722
+ //# sourceMappingURL=SoftapCallTransport.js.map