@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,928 @@
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
+
23
+ import {softapTrace, softapTraceFailure, beginSoftapTrace, resetSoftapTrace} from "../utils/softapTrace"
24
+
25
+ /** Steps in order. Also the teardown order, reversed. */
26
+ export const SOFTAP_STEPS = ["hotspot", "scopedJoin", "acsJoin", "publish", "live"] as const
27
+
28
+ export type SoftapStep = (typeof SOFTAP_STEPS)[number]
29
+
30
+ /**
31
+ * Where the sequence is.
32
+ *
33
+ * `starting` covers every step up to `live` because the caller's only useful distinction is
34
+ * "not yet usable" versus "carrying media"; the step names are for diagnostics, not for branching.
35
+ */
36
+ export type SoftapPhase = "idle" | "starting" | "live" | "stopping" | "failed"
37
+
38
+ /** A failure, named by the step that produced it so the UI and the logs agree on the cause. */
39
+ export class SoftapCallError extends Error {
40
+ constructor(
41
+ readonly step: SoftapStep,
42
+ readonly code: string,
43
+ message: string,
44
+ readonly cause?: unknown,
45
+ ) {
46
+ super(message)
47
+ this.name = "SoftapCallError"
48
+ }
49
+ }
50
+
51
+ /** Gallery sync already learned this: glasses report enabled before the SSID is in the phone scan. */
52
+ export const HOTSPOT_BROADCAST_WAIT_MS = 3_000
53
+
54
+ /**
55
+ * Android's WifiNetworkSpecifier called onUnavailable. The native message lists three causes
56
+ * because the callback does not say which one happened; on the 18:02 Samsung path the SSID was
57
+ * in scan and the join sheet was bypassed — assoc rejected after leaving another AP.
58
+ */
59
+ function isScopedJoinUnavailable(error: unknown): boolean {
60
+ const message = error instanceof Error ? error.message : String(error)
61
+ return /SOFTAP_UNAVAILABLE|ScopedNetworkError\$Unavailable|SSID not in scan, Wi-Fi off, or the system join prompt/i.test(
62
+ message,
63
+ )
64
+ }
65
+
66
+ export type SoftapStepStatus = "pending" | "running" | "done" | "failed"
67
+
68
+ /**
69
+ * One step as the UI should show it. `detail` is the fact the step produced (SSID, phone address,
70
+ * ingest URL, what the glasses last reported); `error` is set only on `failed`.
71
+ */
72
+ export interface SoftapStepState {
73
+ step: SoftapStep
74
+ status: SoftapStepStatus
75
+ detail?: string
76
+ error?: string
77
+ /** Wall-clock ms the step took; present once it is done or failed. */
78
+ durationMs?: number
79
+ }
80
+
81
+ /**
82
+ * Whole-sequence snapshot, re-sent on every transition so a consumer that missed one still holds
83
+ * the truth. This is what the miniapp renders as its join checklist.
84
+ */
85
+ export interface SoftapProgress {
86
+ traceId: string
87
+ phase: SoftapPhase
88
+ steps: SoftapStepState[]
89
+ /** ms since `start()` was called. */
90
+ elapsedMs: number
91
+ }
92
+
93
+ /** Sub-status callback a step can use to narrate what it is doing while it runs. */
94
+ export type SoftapStepReporter = (detail: string) => void
95
+
96
+ export interface SoftapCallDeps {
97
+ /** Enable the glasses hotspot and return its credentials. */
98
+ startHotspot(report?: SoftapStepReporter): Promise<{ssid: string; passphrase: string}>
99
+ /**
100
+ * Wait until the phone can actually see the AP. `setHotspotState` resolves when the glasses
101
+ * accept the command, not when `ap0` is beaconing. A WifiNetworkSpecifier issued too early
102
+ * comes back `Unavailable` in well under a second — it does not keep scanning for the timeout.
103
+ */
104
+ waitUntilHotspotJoinable?(report?: SoftapStepReporter): Promise<void>
105
+ /** Disable it. Must tolerate being called when it was never enabled. */
106
+ stopHotspot(): Promise<void>
107
+ /** Join the hotspot without taking the phone's default route. Resolves to the phone's own IPv4. */
108
+ joinScopedNetwork(ssid: string, passphrase: string, report?: SoftapStepReporter): Promise<string | undefined>
109
+ leaveScopedNetwork(): Promise<void>
110
+ /**
111
+ * Join the meeting. This is what binds the local WHIP listener and arms the ACS raw outputs, so
112
+ * it must resolve before the glasses are told to publish.
113
+ *
114
+ * @returns the URL the glasses must POST their offer to
115
+ */
116
+ joinMeeting(
117
+ args: {ssid: string; passphrase: string; bindAddress?: string},
118
+ report?: SoftapStepReporter,
119
+ ): Promise<{ingestUrl: string}>
120
+ leaveMeeting(): Promise<void>
121
+ /**
122
+ * End the meeting for everyone instead of leaving it. Optional: a host that cannot do this still
123
+ * tears down correctly, it just cannot honour `stop({mode: "end"})`.
124
+ */
125
+ endMeeting?(): Promise<void>
126
+ /** Tell the glasses to publish to [ingestUrl] in host-only ICE mode. */
127
+ startPublishing(args: {ingestUrl: string; traceId: string}, report?: SoftapStepReporter): Promise<void>
128
+ stopPublishing(): Promise<void>
129
+ /**
130
+ * Resolves when a frame has reached ACS, rejects if the feed failed or the deadline passed.
131
+ * Separate from [joinMeeting] because an answered negotiation is not a working call: a session
132
+ * that never delivers a frame reads as healthy behind a frozen tile.
133
+ */
134
+ awaitFirstFrame(report?: SoftapStepReporter): Promise<void>
135
+ }
136
+
137
+ export interface SoftapCallOptions {
138
+ /** Override the minted trace id, so a caller can correlate with logs it already started. */
139
+ traceId?: string
140
+ /**
141
+ * Receives a fresh snapshot on every transition: step begins, step narrates, step ends, sequence
142
+ * ends. Exceptions thrown here are swallowed — a broken listener must not fail the call.
143
+ */
144
+ onProgress?: (progress: SoftapProgress) => void
145
+ /**
146
+ * Narration the caller already showed before the sequence existed — signing in to Teams, asking
147
+ * for a permission. Without it the first `emitProgress` would blank the checklist the wearer is
148
+ * already reading. Only `detail` is taken: the caller reports what it did, it does not get to
149
+ * claim a step ran.
150
+ */
151
+ initialSteps?: SoftapStepState[]
152
+ }
153
+
154
+ function freshSteps(): SoftapStepState[] {
155
+ return SOFTAP_STEPS.map((step) => ({step, status: "pending"}))
156
+ }
157
+
158
+ /** See {@link SoftapCallOptions.initialSteps}. */
159
+ function seededSteps(initial: SoftapStepState[] | undefined): SoftapStepState[] {
160
+ if (!initial?.length) return freshSteps()
161
+ return freshSteps().map((step) => {
162
+ const seed = initial.find((entry) => entry.step === step.step)
163
+ return seed?.detail ? {...step, detail: seed.detail} : step
164
+ })
165
+ }
166
+
167
+ /** A promise plus the function that settles it. */
168
+ function deferred(): {promise: Promise<void>; resolve: () => void} {
169
+ let resolve!: () => void
170
+ const promise = new Promise<void>((settle) => {
171
+ resolve = settle
172
+ })
173
+ return {promise, resolve}
174
+ }
175
+
176
+ /**
177
+ * How a call is being taken down.
178
+ *
179
+ * `leave` takes this device out; `end` terminates the group call for everyone first. Everything
180
+ * after that step is identical, which is the point: End is not a second teardown path, it is one
181
+ * different verb at one step of the same one.
182
+ */
183
+ export type SoftapTeardownMode = "leave" | "end"
184
+
185
+ export interface SoftapStopOptions {
186
+ mode?: SoftapTeardownMode
187
+ keepProgress?: boolean
188
+ }
189
+
190
+ export class SoftapEndNotSupportedError extends Error {
191
+ constructor() {
192
+ super("This host cannot end a meeting for everyone")
193
+ this.name = "SoftapEndNotSupportedError"
194
+ }
195
+ }
196
+
197
+ export class SoftapCallTransport {
198
+ private phase: SoftapPhase = "idle"
199
+ /**
200
+ * Steps completed and not yet undone, in the order they succeeded. Teardown walks this
201
+ * backwards, so a failure halfway through unwinds exactly what was built and nothing else.
202
+ */
203
+ private completed: SoftapStep[] = []
204
+ /**
205
+ * Bumped by every start and stop. A step that resolves after the caller has moved on must not
206
+ * write to the new attempt's state, which is the leave-during-join race.
207
+ */
208
+ private generation = 0
209
+ private stopping: Promise<void> | null = null
210
+ /**
211
+ * The step currently in flight, and a promise that settles only after its body *and* the undo it
212
+ * runs when it finds itself cancelled are both finished.
213
+ *
214
+ * This is what [stop] waits on. A step that resolves late still owns a resource — a hotspot that
215
+ * came up after the wearer left — and its release happens inside the step, out of the teardown's
216
+ * sight. Without this wait, `stop()` resolves while that release is still pending and the next
217
+ * call brings a hotspot up straight into it.
218
+ */
219
+ private running: {step: SoftapStep; settled: Promise<void>} | null = null
220
+ /** True once [start] has been called at least once, so [stop] can tell "cancelled" from "reusable". */
221
+ private startedEver = false
222
+ /**
223
+ * A [stop] that landed before the sequence ever began.
224
+ *
225
+ * There is nothing to unwind in that case, so the flag is the only thing that can carry the
226
+ * cancellation forward: a `start()` arriving afterwards belongs to the attempt that was just
227
+ * cancelled and must refuse rather than build a call nobody is waiting for.
228
+ */
229
+ private cancelledBeforeStart = false
230
+ /** Failed undos for this attempt, retained across repeated stops. See [lastTeardownFailures]. */
231
+ private teardownFailures: SoftapStep[] = []
232
+ /**
233
+ * Raised the instant a teardown is decided, before any resource is touched.
234
+ *
235
+ * Everything that watches the call for failure — a lost hotspot above all — has to be able to ask
236
+ * "was this supposed to happen?". Without the flag, releasing the scoped network during a
237
+ * successful Leave looks exactly like the glasses walking out of range, and the wearer gets an
238
+ * error screen for a call that ended the way they asked.
239
+ */
240
+ private terminating = false
241
+ /** How this teardown ends the meeting. Read by the `acsJoin` undo. */
242
+ private teardownMode: SoftapTeardownMode = "leave"
243
+ /** Set when `stop({mode: "end"})` could not end for everyone. The caller must not claim it did. */
244
+ private endFailure: unknown = null
245
+ private hotspot: {ssid: string; passphrase: string} | null = null
246
+ private ingestUrl: string | null = null
247
+ private steps: SoftapStepState[] = freshSteps()
248
+ private stepStartedAt = new Map<SoftapStep, number>()
249
+ private startedAt = 0
250
+ private traceId = ""
251
+ private onProgress: ((progress: SoftapProgress) => void) | undefined
252
+
253
+ constructor(private readonly deps: SoftapCallDeps) {}
254
+
255
+ currentPhase(): SoftapPhase {
256
+ return this.phase
257
+ }
258
+
259
+ /**
260
+ * True once a teardown has been decided. Anything that would otherwise report a failure — a lost
261
+ * hotspot, a dropped ACS call — must check this first: after the wearer asks to leave, those are
262
+ * the sound of it working.
263
+ */
264
+ isTerminating(): boolean {
265
+ return this.terminating
266
+ }
267
+
268
+ /** What the UI should show right now. Safe to call in any phase. */
269
+ progress(): SoftapProgress {
270
+ return {
271
+ traceId: this.traceId,
272
+ phase: this.phase,
273
+ steps: this.steps.map((step) => ({...step})),
274
+ elapsedMs: this.startedAt ? Date.now() - this.startedAt : 0,
275
+ }
276
+ }
277
+
278
+ private emitProgress(): void {
279
+ const listener = this.onProgress
280
+ if (!listener) return
281
+ try {
282
+ listener(this.progress())
283
+ } catch (error) {
284
+ softapTraceFailure("softap_progress_listener_threw", {
285
+ reason: error instanceof Error ? error.message : String(error),
286
+ })
287
+ }
288
+ }
289
+
290
+ private setStep(step: SoftapStep, patch: Partial<SoftapStepState>): void {
291
+ this.steps = this.steps.map((entry) => (entry.step === step ? {...entry, ...patch} : entry))
292
+ this.emitProgress()
293
+ }
294
+
295
+ /** Narration for a running step: what the phone or the glasses just reported. */
296
+ private note(generation: number, step: SoftapStep, detail: string): void {
297
+ if (generation !== this.generation) {
298
+ // A step that is still narrating after the sequence moved on. The detail is dropped rather
299
+ // than written onto the new attempt's checklist, and the drop is said out loud because a
300
+ // step that goes quiet here is usually one that is still holding a native call open.
301
+ softapTrace("softap_step_note_dropped", {step, generation, current: this.generation, detail})
302
+ return
303
+ }
304
+ softapTrace("softap_step_note", {step, detail})
305
+ this.setStep(step, {detail})
306
+ }
307
+
308
+ /** Steps currently built up, oldest first. Empty when nothing needs tearing down. */
309
+ activeSteps(): SoftapStep[] {
310
+ return [...this.completed]
311
+ }
312
+
313
+ /** The URL handed to the glasses, for diagnostics. Null outside a live attempt. */
314
+ currentIngestUrl(): string | null {
315
+ return this.ingestUrl
316
+ }
317
+
318
+ /**
319
+ * Steps whose undo threw during the last teardown, so the caller can refuse the next call.
320
+ *
321
+ * Teardown deliberately swallows these to keep unwinding — but a hotspot that would not turn off
322
+ * is exactly the state the next call cannot be built on, and starting anyway is what produced
323
+ * "Cannot start glasses hotspot" followed by a scoped join that never found the SSID.
324
+ */
325
+ lastTeardownFailures(): SoftapStep[] {
326
+ // Logged on read rather than only on write, because this is the moment the list turns into a
327
+ // refusal for the next call: a wearer told to power-cycle the hotspot needs the reason to be
328
+ // findable, and the write happened somewhere in the middle of a noisy teardown.
329
+ if (this.teardownFailures.length > 0) {
330
+ softapTraceFailure("softap_teardown_failures_read", {steps: this.teardownFailures.join(",")})
331
+ }
332
+ return [...this.teardownFailures]
333
+ }
334
+
335
+ /**
336
+ * Runs the sequence. On any failure the partial sequence is torn down before the error is
337
+ * rethrown, so a failed start never leaves a hotspot up or a publisher running.
338
+ */
339
+ async start(options: SoftapCallOptions = {}): Promise<void> {
340
+ if (this.cancelledBeforeStart) {
341
+ // The wearer's Cancel landed before the sequence began, so there is no step to name and no
342
+ // trace id yet. This line is the only evidence that the flag did its job rather than the
343
+ // join having silently never been asked for.
344
+ softapTraceFailure("softap_call_refused", {reason: "cancelled before start"})
345
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP call was cancelled before it started")
346
+ }
347
+ if (this.phase !== "idle" && this.phase !== "failed") {
348
+ softapTraceFailure("softap_call_refused", {reason: "already active", phase: this.phase})
349
+ throw new SoftapCallError("hotspot", "ALREADY_ACTIVE", `A SoftAP call is already ${this.phase}`)
350
+ }
351
+ this.startedEver = true
352
+ const generation = ++this.generation
353
+ this.phase = "starting"
354
+ this.terminating = false
355
+ this.teardownMode = "leave"
356
+ this.endFailure = null
357
+ this.completed = []
358
+ this.ingestUrl = null
359
+ this.hotspot = null
360
+ this.teardownFailures = []
361
+ this.steps = seededSteps(options.initialSteps)
362
+ this.stepStartedAt.clear()
363
+ this.startedAt = Date.now()
364
+ this.onProgress = options.onProgress
365
+ const traceId = beginSoftapTrace(options.traceId)
366
+ this.traceId = traceId
367
+ softapTrace("softap_call_start", {traceId})
368
+ this.emitProgress()
369
+
370
+ try {
371
+ await this.step(generation, "hotspot", "HOTSPOT_FAILED", async (report) => {
372
+ report("Asking the glasses to turn on their hotspot")
373
+ const hotspot = await this.deps.startHotspot(report)
374
+ if (!hotspot.ssid) {
375
+ throw new Error("the glasses reported no hotspot SSID")
376
+ }
377
+ this.hotspot = hotspot
378
+ // The passphrase never reaches the log; softapTrace redacts it by key, and only the SSID
379
+ // is useful for matching against the phone's Wi-Fi state anyway.
380
+ softapTrace("hotspot_enabled", {ssid: hotspot.ssid})
381
+ report(`Hotspot ${hotspot.ssid} is on; waiting for it to broadcast`)
382
+ await this.deps.waitUntilHotspotJoinable?.(report)
383
+ softapTrace("hotspot_broadcast_wait_done", {ssid: hotspot.ssid})
384
+ report(`Hotspot ${hotspot.ssid}`)
385
+ })
386
+
387
+ const hotspot = this.requireHotspot()
388
+ let bindAddress: string | undefined
389
+ await this.step(generation, "scopedJoin", "SCOPED_JOIN_FAILED", async (report) => {
390
+ report(
391
+ `Phone joining ${hotspot.ssid}. Turn Wi-Fi on if a panel opens — Teams stays on cellular.`,
392
+ )
393
+ bindAddress = await this.deps.joinScopedNetwork(hotspot.ssid, hotspot.passphrase, report)
394
+ softapTrace("scoped_network_joined", {bindAddress: bindAddress ?? "unknown"})
395
+ if (bindAddress) report(`Phone is ${bindAddress} on ${hotspot.ssid}`)
396
+ })
397
+
398
+ await this.step(generation, "acsJoin", "ACS_JOIN_FAILED", async (report) => {
399
+ report(bindAddress ? `Opening video receiver on ${bindAddress}, then joining Teams` : "Joining Teams")
400
+ const {ingestUrl} = await this.deps.joinMeeting(
401
+ {
402
+ ssid: hotspot.ssid,
403
+ passphrase: hotspot.passphrase,
404
+ bindAddress,
405
+ },
406
+ report,
407
+ )
408
+ if (!ingestUrl) {
409
+ // Without a bound listener there is nowhere for the glasses to publish, and telling them
410
+ // to publish anyway produces a failure several seconds later on the wrong device.
411
+ throw new Error("the meeting reported no ingest URL")
412
+ }
413
+ this.ingestUrl = ingestUrl
414
+ softapTrace("acs_joined", {ingestUrl})
415
+ report(`Receiver ready at ${ingestUrl}`)
416
+ })
417
+
418
+ const ingestUrl = this.requireIngestUrl()
419
+ await this.step(generation, "publish", "PUBLISH_FAILED", async (report) => {
420
+ report("Telling the glasses to start the camera and publish to the phone")
421
+ await this.deps.startPublishing({ingestUrl, traceId}, report)
422
+ softapTrace("glasses_publishing", {ingestUrl})
423
+ report("Glasses camera is streaming to the phone")
424
+ })
425
+
426
+ await this.step(generation, "live", "NO_FIRST_FRAME", async (report) => {
427
+ report("Waiting for the first video frame to reach Teams")
428
+ await this.deps.awaitFirstFrame(report)
429
+ softapTrace("first_frame_in_acs")
430
+ report("Video is live in the meeting")
431
+ })
432
+
433
+ if (generation !== this.generation) {
434
+ // Every step succeeded and the call is nevertheless not this transport's any more. The
435
+ // steps released themselves on the way past, so there is nothing to undo — but a join
436
+ // that got all the way to a frame and then vanished is otherwise a log that simply stops.
437
+ softapTraceFailure("softap_call_abandoned_at_live", {generation, current: this.generation})
438
+ return
439
+ }
440
+ this.phase = "live"
441
+ softapTrace("softap_call_live")
442
+ this.emitProgress()
443
+ } catch (error) {
444
+ // Unwind before rethrowing. A caller that sees a rejection is entitled to assume nothing was
445
+ // left running, and a hotspot left up is both a battery cost and a second call's failure.
446
+ await this.stop({keepProgress: true})
447
+ this.phase = "failed"
448
+ this.emitProgress()
449
+ throw error
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Tears down in exact reverse order, and only what was built.
455
+ *
456
+ * Every step is attempted even if an earlier one throws: a failure to stop the publisher must
457
+ * not leave the hotspot on. Concurrent calls share one teardown rather than racing each other
458
+ * through the same resources, and a second `stop()` after one finished is a no-op — this is the
459
+ * only SoftAP exit, so every terminal path can call it without checking whether another already
460
+ * did.
461
+ *
462
+ * `mode: "end"` swaps the meeting verb and nothing else. If ending for everyone fails, the rest of
463
+ * the teardown still runs and the failure is rethrown at the end, so the caller can tell the
464
+ * wearer they left a meeting that may still be live rather than inventing a clean end.
465
+ */
466
+ async stop(options: SoftapStopOptions = {}): Promise<void> {
467
+ // Intent before action, always: a watcher must be able to tell a deliberate teardown from a
468
+ // failure even during the very first await below.
469
+ this.terminating = true
470
+ if (options.mode) this.teardownMode = options.mode
471
+ if (this.stopping) {
472
+ softapTrace("softap_stop_joined_in_flight", {mode: this.teardownMode})
473
+ return this.stopping
474
+ }
475
+ const running = this.running
476
+ if (this.completed.length === 0 && this.phase === "idle" && !running) {
477
+ // Nothing was built, so there is nothing to unwind — but a start() that has not run yet
478
+ // still has to be refused, and a generation bump still has to invalidate anything holding
479
+ // the old one.
480
+ this.generation++
481
+ if (!this.startedEver) this.cancelledBeforeStart = true
482
+ softapTrace("softap_stop_nothing_built", {
483
+ // The distinction the next `start()` turns on: a transport that was never started refuses
484
+ // outright, one that has already run is reusable.
485
+ cancelledBeforeStart: this.cancelledBeforeStart,
486
+ generation: this.generation,
487
+ })
488
+ return
489
+ }
490
+
491
+ this.generation++
492
+ this.phase = "stopping"
493
+ this.endFailure = null
494
+ softapTrace("softap_call_stop", {steps: this.completed.join(","), mode: this.teardownMode})
495
+ this.emitProgress()
496
+
497
+ this.stopping = (async () => {
498
+ // The generation bump above has already told the in-flight step to release whatever it
499
+ // produced. Waiting for that release is what makes a resolved `stop()` mean "nothing from
500
+ // this call is still coming". Deliberately unbounded: a native call that never returns must
501
+ // hold the next call back, never let it race this one's cleanup.
502
+ if (running) {
503
+ softapTrace("softap_stop_waiting_for_step", {step: running.step})
504
+ const waitStartedAt = Date.now()
505
+ await running.settled
506
+ // This wait is unbounded by design, so its duration is the difference between "the leave
507
+ // was slow" and "the leave was held by a native call that had not returned".
508
+ softapTrace("softap_stop_step_settled", {step: running.step, waitedMs: Date.now() - waitStartedAt})
509
+ }
510
+ // The late step may have recorded a failed self-undo while we waited. Preserve it,
511
+ // and any earlier teardown result, until start() explicitly begins a new attempt.
512
+ const failures: SoftapStep[] = [...this.teardownFailures]
513
+ for (const step of [...this.completed].reverse()) {
514
+ const undoStartedAt = Date.now()
515
+ try {
516
+ await this.undo(step)
517
+ softapTrace("softap_step_undone", {step, durationMs: Date.now() - undoStartedAt})
518
+ } catch (error) {
519
+ // Recorded, not rethrown: the remaining steps still have to be undone. The caller reads
520
+ // them back through [lastTeardownFailures] and refuses the next call, because a hotspot
521
+ // that would not turn off is exactly the state the next call cannot build on.
522
+ failures.push(step)
523
+ softapTraceFailure("softap_step_undo_failed", {
524
+ step,
525
+ durationMs: Date.now() - undoStartedAt,
526
+ reason: error instanceof Error ? error.message : String(error),
527
+ })
528
+ }
529
+ }
530
+ this.completed = []
531
+ this.hotspot = null
532
+ this.ingestUrl = null
533
+ this.phase = "idle"
534
+ this.teardownFailures = failures
535
+ softapTrace("softap_call_stopped", {undoFailures: failures.join(",")})
536
+ resetSoftapTrace()
537
+ // A failed start keeps its checklist so the UI can show which step broke; a deliberate
538
+ // leave wipes it, because there is nothing left to explain.
539
+ if (!options.keepProgress) {
540
+ this.steps = freshSteps()
541
+ this.emitProgress()
542
+ }
543
+ })()
544
+
545
+ try {
546
+ await this.stopping
547
+ } finally {
548
+ this.stopping = null
549
+ }
550
+ // Rethrown last, after every resource is released. An End that could not terminate the meeting
551
+ // has still taken this device out; only the claim about the others is wrong.
552
+ const endFailure = this.endFailure
553
+ this.endFailure = null
554
+ if (endFailure) throw endFailure
555
+ }
556
+
557
+ private async undo(step: SoftapStep): Promise<void> {
558
+ switch (step) {
559
+ // `live` is an observation, not a resource — there is nothing to release.
560
+ case "live":
561
+ return
562
+ case "publish":
563
+ return this.deps.stopPublishing()
564
+ case "acsJoin":
565
+ return this.leaveOrEndMeeting()
566
+ case "scopedJoin":
567
+ return this.deps.leaveScopedNetwork()
568
+ case "hotspot":
569
+ return this.deps.stopHotspot()
570
+ }
571
+ }
572
+
573
+ /**
574
+ * The one step End changes.
575
+ *
576
+ * A failed End falls back to leaving, so the wearer is out either way, and the original failure is
577
+ * kept for [stop] to rethrow. Recording it rather than throwing here is what keeps the hotspot
578
+ * teardown — the steps after this one — unconditional.
579
+ */
580
+ private async leaveOrEndMeeting(): Promise<void> {
581
+ if (this.teardownMode !== "end") return this.deps.leaveMeeting()
582
+ const endMeeting = this.deps.endMeeting
583
+ if (!endMeeting) {
584
+ this.endFailure = new SoftapEndNotSupportedError()
585
+ return this.deps.leaveMeeting()
586
+ }
587
+ try {
588
+ await endMeeting()
589
+ softapTrace("softap_meeting_ended_for_everyone")
590
+ } catch (error) {
591
+ this.endFailure = error
592
+ softapTraceFailure("softap_end_for_everyone_failed", {
593
+ reason: error instanceof Error ? error.message : String(error),
594
+ })
595
+ // Native ends the local call even when the hang-up is refused, so this is a belt-and-braces
596
+ // leave rather than a second teardown: it must not resurrect the failure it is covering for.
597
+ await this.deps.leaveMeeting().catch(() => undefined)
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Runs one step, records it as undoable, and maps any throw to a [SoftapCallError] naming the
603
+ * step. The generation check is what makes leaving mid-step safe: a step that resolves after the
604
+ * caller gave up is not recorded, so teardown does not try to undo it twice.
605
+ */
606
+ private async step(
607
+ generation: number,
608
+ step: SoftapStep,
609
+ code: string,
610
+ run: (report: SoftapStepReporter) => Promise<void>,
611
+ ): Promise<void> {
612
+ if (generation !== this.generation) {
613
+ // The sequence stopped between two steps. Named here because the caller only ever sees one
614
+ // CANCELLED error, and which step it never reached is the thing worth knowing.
615
+ softapTraceFailure("softap_step_skipped_after_cancel", {step, generation, current: this.generation})
616
+ throw new SoftapCallError(step, "CANCELLED", `SoftAP call was cancelled before ${step}`)
617
+ }
618
+ // Published before the first await so a `stop()` on the very next tick can see it. Settled in
619
+ // the `finally`, after any self-undo, so waiting on it means the step owns nothing any more.
620
+ const settle = deferred()
621
+ this.running = {step, settled: settle.promise}
622
+ try {
623
+ await this.runStep(generation, step, code, run)
624
+ } finally {
625
+ if (this.running?.settled === settle.promise) this.running = null
626
+ settle.resolve()
627
+ }
628
+ }
629
+
630
+ /** The step body itself. Split out so [step] can publish and settle {@link running} around it. */
631
+ private async runStep(
632
+ generation: number,
633
+ step: SoftapStep,
634
+ code: string,
635
+ run: (report: SoftapStepReporter) => Promise<void>,
636
+ ): Promise<void> {
637
+ softapTrace("softap_step_begin", {step})
638
+ const startedAt = Date.now()
639
+ this.stepStartedAt.set(step, startedAt)
640
+ this.setStep(step, {status: "running", error: undefined})
641
+ const report: SoftapStepReporter = (detail) => this.note(generation, step, detail)
642
+ try {
643
+ await run(report)
644
+ } catch (error) {
645
+ const reason = error instanceof Error ? error.message : String(error)
646
+ softapTraceFailure("softap_step_failed", {step, code, reason})
647
+ if (generation === this.generation) {
648
+ this.setStep(step, {status: "failed", error: reason, durationMs: Date.now() - startedAt})
649
+ }
650
+ throw new SoftapCallError(step, code, error instanceof Error ? error.message : `${step} failed`, error)
651
+ }
652
+ if (generation !== this.generation) {
653
+ // The step succeeded after the caller gave up. Release it here rather than recording it for
654
+ // the teardown to find: that teardown may already have walked past this step, or finished
655
+ // altogether, in which case nothing else ever would. This is the leak the generation guard
656
+ // exists to close — a meeting joined a few milliseconds after the user left.
657
+ softapTrace("softap_step_completed_after_cancel", {step})
658
+ await this.undoSafely(step)
659
+ throw new SoftapCallError(step, "CANCELLED", `SoftAP call was cancelled during ${step}`)
660
+ }
661
+ this.completed.push(step)
662
+ softapTrace("softap_step_done", {step, durationMs: Date.now() - startedAt})
663
+ this.setStep(step, {status: "done", durationMs: Date.now() - startedAt})
664
+ }
665
+
666
+ /** Undo that reports rather than throws, for the cancellation path where there is no caller. */
667
+ private async undoSafely(step: SoftapStep): Promise<void> {
668
+ try {
669
+ await this.undo(step)
670
+ } catch (error) {
671
+ if (!this.teardownFailures.includes(step)) this.teardownFailures.push(step)
672
+ softapTraceFailure("softap_step_undo_failed", {
673
+ step,
674
+ reason: error instanceof Error ? error.message : String(error),
675
+ })
676
+ }
677
+ }
678
+
679
+ private requireHotspot(): {ssid: string; passphrase: string} {
680
+ const hotspot = this.hotspot
681
+ if (!hotspot) throw new SoftapCallError("hotspot", "HOTSPOT_FAILED", "no hotspot credentials")
682
+ return hotspot
683
+ }
684
+
685
+ private requireIngestUrl(): string {
686
+ const url = this.ingestUrl
687
+ if (!url) throw new SoftapCallError("acsJoin", "ACS_JOIN_FAILED", "no ingest URL")
688
+ return url
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Binds the sequence to the real subsystems.
694
+ *
695
+ * Kept separate from the class so the ordering above is tested against fakes rather than against
696
+ * BLE and ACS. The only logic here is adapting shapes; anything that needs a decision belongs in
697
+ * the class.
698
+ *
699
+ * @param packageName the miniapp that owns the call
700
+ * @param meeting the meeting to join, and how to observe its media health
701
+ */
702
+ export function createSoftapCallDeps(args: {
703
+ packageName: string
704
+ meetingUrl: string
705
+ token: string
706
+ displayName?: string
707
+ /** Resolves when the meeting reports a frame reached ACS; rejects on a failed feed. */
708
+ awaitFirstFrame: () => Promise<void>
709
+ subsystems: {
710
+ setHotspotState: (enabled: boolean) => Promise<{state: string; ssid?: string; password?: string}>
711
+ joinScopedNetwork: (ssid: string, passphrase: string) => Promise<string | undefined>
712
+ leaveScopedNetwork: () => Promise<void>
713
+ joinMeeting: (
714
+ packageName: string,
715
+ options: {
716
+ meetingUrl: string
717
+ token: string
718
+ videoSource: {type: "softap"; ssid?: string; passphrase?: string; bindAddress?: string}
719
+ displayName?: string
720
+ },
721
+ ) => Promise<unknown>
722
+ leaveMeeting: (packageName: string) => Promise<void>
723
+ /** Terminate the group call for everyone. Absent on hosts that cannot. */
724
+ endMeeting?: (packageName: string) => Promise<void>
725
+ ingestUrl: () => string | null
726
+ startPublishing: (
727
+ packageName: string,
728
+ options: {streamUrl: string; ice: {stun: string}; traceId: string; captureAudio?: boolean},
729
+ ) => Promise<unknown>
730
+ stopPublishing: (packageName: string) => Promise<void>
731
+ /**
732
+ * Whether the host is taking the wearer's voice off the glasses over BLE LC3 for this call.
733
+ *
734
+ * Asked after the meeting join and before the publish, because that is the only moment the
735
+ * answer is both known (the host has seen the native's capabilities) and still actionable (the
736
+ * glasses have not been told what to capture). True means the WHIP publish is video-only;
737
+ * false means the glasses put their microphone on the WHIP track as they always have. Absent
738
+ * on hosts that only have the WHIP audio path.
739
+ */
740
+ glassesLc3Uplink?: () => boolean
741
+ /**
742
+ * Prove the phone can reach the glasses over the hotspot it just joined. Optional because
743
+ * only Android hosts have the scoped network handle; when present its verdict is narrated
744
+ * into the scoped-join step and a failure is reported, not thrown — the glasses-to-phone
745
+ * direction is what the call actually needs, and that is tested by the publish step.
746
+ */
747
+ probeGateway?: () => Promise<{reachable: boolean; detail: string}>
748
+ /**
749
+ * Wait for the phone's default network to be validated again after the hotspot join took Wi-Fi
750
+ * away. Optional because only Android hosts can answer it.
751
+ *
752
+ * Not fatal when it reports an unusable network: the wearer is told, and the join is attempted
753
+ * anyway. Aborting here would fail calls that recover a second later, and the ACS join has its
754
+ * own bounded timeout for the case that does not.
755
+ */
756
+ awaitValidatedDefaultNetwork?: () => Promise<{usable: boolean; detail: string} | null>
757
+ /**
758
+ * Glasses `stream_status` events, so the publish step can say "camera starting" and "offer
759
+ * posted" instead of going quiet for the whole BLE round trip. Returns an unsubscribe.
760
+ */
761
+ onGlassesStreamStatus?: (
762
+ listener: (event: {status: string; streamId?: string; reason?: string; error?: string}) => void,
763
+ ) => () => void
764
+ }
765
+ /** Override only in tests. Production waits the gallery-proven broadcast window. */
766
+ hotspotBroadcastWaitMs?: number
767
+ }): SoftapCallDeps {
768
+ const {packageName, subsystems} = args
769
+ const hotspotBroadcastWaitMs = args.hotspotBroadcastWaitMs ?? HOTSPOT_BROADCAST_WAIT_MS
770
+ return {
771
+ startHotspot: async (report) => {
772
+ const enable = async () => {
773
+ const status = await subsystems.setHotspotState(true)
774
+ if (status.state !== "enabled" || !status.ssid) {
775
+ throw new Error(`the glasses hotspot did not start (state=${status.state})`)
776
+ }
777
+ if (!status.password) {
778
+ throw new Error("the glasses hotspot reported no password")
779
+ }
780
+ report?.(`Glasses report hotspot ${status.ssid} enabled`)
781
+ return {ssid: status.ssid, passphrase: status.password}
782
+ }
783
+ try {
784
+ return await enable()
785
+ } catch (error) {
786
+ if (error instanceof Error && /no password/.test(error.message)) throw error
787
+ // Cancel-then-start races the previous disable: the glasses report disabled (or no SSID)
788
+ // and the UI said "Couldn't start glasses hotspot" before step 2 ran on a leftover AP.
789
+ softapTraceFailure("hotspot_enable_retry", {
790
+ reason: error instanceof Error ? error.message : String(error),
791
+ })
792
+ report?.("Glasses hotspot did not start; turning it off and trying again")
793
+ await subsystems.setHotspotState(false)
794
+ if (hotspotBroadcastWaitMs > 0) {
795
+ await new Promise<void>(resolve => setTimeout(resolve, Math.min(1_000, hotspotBroadcastWaitMs)))
796
+ }
797
+ return await enable()
798
+ }
799
+ },
800
+ waitUntilHotspotJoinable: async (report) => {
801
+ if (hotspotBroadcastWaitMs <= 0) return
802
+ softapTrace("hotspot_broadcast_wait", {ms: hotspotBroadcastWaitMs})
803
+ report?.(`Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`)
804
+ await new Promise<void>(resolve => setTimeout(resolve, hotspotBroadcastWaitMs))
805
+ },
806
+ stopHotspot: async () => {
807
+ await subsystems.setHotspotState(false)
808
+ },
809
+ joinScopedNetwork: async (ssid, passphrase, report) => {
810
+ const joinOnce = (nextSsid: string, nextPassphrase: string) =>
811
+ subsystems.joinScopedNetwork(nextSsid, nextPassphrase)
812
+ let address: string | undefined
813
+ try {
814
+ address = await joinOnce(ssid, passphrase)
815
+ } catch (error) {
816
+ if (!isScopedJoinUnavailable(error)) throw error
817
+ // First specifier left the phone's previous Wi-Fi and assoc-rejected the glasses AP.
818
+ // Cycle the AP and join again from an idle STA — the radio is free now.
819
+ softapTraceFailure("scoped_join_unavailable_retry", {ssid})
820
+ report?.("Phone couldn't join; cycling the glasses hotspot and trying again")
821
+ await subsystems.setHotspotState(false)
822
+ const status = await subsystems.setHotspotState(true)
823
+ if (status.state !== "enabled" || !status.ssid || !status.password) throw error
824
+ if (hotspotBroadcastWaitMs > 0) {
825
+ report?.(
826
+ `Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`,
827
+ )
828
+ await new Promise<void>(resolve => setTimeout(resolve, hotspotBroadcastWaitMs))
829
+ }
830
+ address = await joinOnce(status.ssid, status.password)
831
+ }
832
+ if (subsystems.probeGateway) {
833
+ report?.(`Phone is ${address ?? "on the hotspot"}; checking it can reach the glasses`)
834
+ try {
835
+ const probe = await subsystems.probeGateway()
836
+ softapTrace(probe.reachable ? "gateway_probe_ok" : "gateway_probe_failed", {detail: probe.detail})
837
+ report?.(
838
+ probe.reachable
839
+ ? `Phone ${address ?? ""} ↔ glasses OK (${probe.detail})`
840
+ : `Phone ${address ?? ""} joined, but cannot reach the glasses: ${probe.detail}`,
841
+ )
842
+ } catch (error) {
843
+ softapTraceFailure("gateway_probe_threw", {
844
+ reason: error instanceof Error ? error.message : String(error),
845
+ })
846
+ }
847
+ }
848
+ return address
849
+ },
850
+ leaveScopedNetwork: () => subsystems.leaveScopedNetwork(),
851
+ joinMeeting: async ({ssid, passphrase, bindAddress}, report) => {
852
+ // The hotspot join just took this phone off Wi-Fi, so the route Teams needs is whatever
853
+ // Android promoted in its place. Waiting for it to validate is what stopped the ACS join
854
+ // from burning its whole timeout on DNS that could not resolve yet.
855
+ if (subsystems.awaitValidatedDefaultNetwork) {
856
+ report?.("Waiting for this phone's mobile data to take over so Teams can connect")
857
+ try {
858
+ const network = await subsystems.awaitValidatedDefaultNetwork()
859
+ if (network) {
860
+ softapTrace(network.usable ? "default_network_ok" : "default_network_unvalidated", {
861
+ detail: network.detail,
862
+ })
863
+ report?.(
864
+ network.usable
865
+ ? `Internet is on ${network.detail}`
866
+ : `Internet is not confirmed yet (${network.detail}); joining Teams anyway`,
867
+ )
868
+ }
869
+ } catch (error) {
870
+ softapTraceFailure("default_network_check_threw", {
871
+ reason: error instanceof Error ? error.message : String(error),
872
+ })
873
+ }
874
+ }
875
+ report?.("Binding the video receiver and joining Teams over cellular")
876
+ await subsystems.joinMeeting(packageName, {
877
+ meetingUrl: args.meetingUrl,
878
+ token: args.token,
879
+ videoSource: {type: "softap", ssid, passphrase, bindAddress},
880
+ displayName: args.displayName,
881
+ })
882
+ // The listener binds during the join, so the URL only exists now.
883
+ return {ingestUrl: subsystems.ingestUrl() ?? ""}
884
+ },
885
+ leaveMeeting: () => subsystems.leaveMeeting(packageName),
886
+ ...(subsystems.endMeeting ? {endMeeting: () => subsystems.endMeeting!(packageName)} : {}),
887
+ startPublishing: async ({ingestUrl, traceId}, report) => {
888
+ // Narrate the glasses side while the BLE start command is in flight. `initializing` means
889
+ // the glasses accepted the command and are opening the camera; `streaming` means the WHIP
890
+ // offer was answered and ICE connected; anything else is the reason it did not.
891
+ const unsubscribe = subsystems.onGlassesStreamStatus?.((event) => {
892
+ if (event.status === "initializing") {
893
+ report?.("Glasses accepted the command: camera starting, gathering ICE, posting offer to the phone")
894
+ } else if (event.status === "streaming") {
895
+ report?.("Glasses are streaming to the phone")
896
+ } else if (event.status === "error") {
897
+ report?.(`Glasses reported: ${event.error ?? event.reason ?? "stream error"}`)
898
+ } else if (event.status === "reconnecting") {
899
+ report?.(`Glasses reconnecting: ${event.reason ?? ""}`)
900
+ }
901
+ })
902
+ // Decided before the command goes out, never after: the glasses cannot drop an audio track
903
+ // they already negotiated, and two live copies of the wearer's voice in one call is worse
904
+ // than either one alone.
905
+ const lc3Uplink = subsystems.glassesLc3Uplink?.() ?? false
906
+ softapTrace("publish_audio_decision", {captureAudio: !lc3Uplink, micTransport: lc3Uplink ? "ble-lc3" : "whip"})
907
+ report?.(
908
+ lc3Uplink
909
+ ? "Publishing video only; the wearer's voice comes over Bluetooth LC3"
910
+ : "Publishing video and the glasses microphone",
911
+ )
912
+ try {
913
+ await subsystems.startPublishing(packageName, {
914
+ streamUrl: ingestUrl,
915
+ // Empty STUN server means host-only: there is no route from the hotspot to a STUN server,
916
+ // so a configured one would add doomed gathering to every call.
917
+ ice: {stun: ""},
918
+ traceId,
919
+ captureAudio: !lc3Uplink,
920
+ })
921
+ } finally {
922
+ unsubscribe?.()
923
+ }
924
+ },
925
+ stopPublishing: () => subsystems.stopPublishing(packageName),
926
+ awaitFirstFrame: args.awaitFirstFrame,
927
+ }
928
+ }