@mentra/engine 3.2.0-dev.217 → 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 (87) 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 +3 -1
  4. package/build/index.d.ts.map +1 -1
  5. package/build/index.js +2 -1
  6. package/build/index.js.map +1 -1
  7. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
  8. package/build/react/MentraLiveOtaFlow.js +19 -3
  9. package/build/react/MentraLiveOtaFlow.js.map +1 -1
  10. package/build/react/useMentraLiveOta.d.ts +12 -0
  11. package/build/react/useMentraLiveOta.d.ts.map +1 -1
  12. package/build/react/useMentraLiveOta.js +31 -5
  13. package/build/react/useMentraLiveOta.js.map +1 -1
  14. package/build/services/AcsMeetingService.d.ts +360 -10
  15. package/build/services/AcsMeetingService.d.ts.map +1 -1
  16. package/build/services/AcsMeetingService.js +836 -19
  17. package/build/services/AcsMeetingService.js.map +1 -1
  18. package/build/services/AudioPlaybackService.d.ts +6 -0
  19. package/build/services/AudioPlaybackService.d.ts.map +1 -1
  20. package/build/services/AudioPlaybackService.js +4 -3
  21. package/build/services/AudioPlaybackService.js.map +1 -1
  22. package/build/services/GlassesMicProbe.d.ts +78 -0
  23. package/build/services/GlassesMicProbe.d.ts.map +1 -0
  24. package/build/services/GlassesMicProbe.js +258 -0
  25. package/build/services/GlassesMicProbe.js.map +1 -0
  26. package/build/services/LocalMiniappRuntime.d.ts +83 -0
  27. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  28. package/build/services/LocalMiniappRuntime.js +714 -39
  29. package/build/services/LocalMiniappRuntime.js.map +1 -1
  30. package/build/services/MentraJSLogPipeline.d.ts +39 -0
  31. package/build/services/MentraJSLogPipeline.d.ts.map +1 -1
  32. package/build/services/MentraJSLogPipeline.js +59 -3
  33. package/build/services/MentraJSLogPipeline.js.map +1 -1
  34. package/build/services/MentraJSRouter.d.ts +1 -1
  35. package/build/services/MentraJSRouter.d.ts.map +1 -1
  36. package/build/services/MentraJSRouter.js +2 -2
  37. package/build/services/MentraJSRouter.js.map +1 -1
  38. package/build/services/MicStateCoordinator.d.ts +22 -0
  39. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  40. package/build/services/MicStateCoordinator.js +34 -3
  41. package/build/services/MicStateCoordinator.js.map +1 -1
  42. package/build/services/OtaErrorMapping.d.ts +25 -2
  43. package/build/services/OtaErrorMapping.d.ts.map +1 -1
  44. package/build/services/OtaErrorMapping.js +68 -23
  45. package/build/services/OtaErrorMapping.js.map +1 -1
  46. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  47. package/build/services/OtaInstallCoordinator.js +6 -1
  48. package/build/services/OtaInstallCoordinator.js.map +1 -1
  49. package/build/services/PhoneStreamCoordinator.d.ts +8 -0
  50. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  51. package/build/services/PhoneStreamCoordinator.js +2 -0
  52. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  53. package/build/services/SoftapCallTransport.d.ts +365 -0
  54. package/build/services/SoftapCallTransport.d.ts.map +1 -0
  55. package/build/services/SoftapCallTransport.js +722 -0
  56. package/build/services/SoftapCallTransport.js.map +1 -0
  57. package/build/services/SoftapCleanupBarrier.d.ts +45 -0
  58. package/build/services/SoftapCleanupBarrier.d.ts.map +1 -0
  59. package/build/services/SoftapCleanupBarrier.js +51 -0
  60. package/build/services/SoftapCleanupBarrier.js.map +1 -0
  61. package/build/utils/pcm16.d.ts +35 -0
  62. package/build/utils/pcm16.d.ts.map +1 -0
  63. package/build/utils/pcm16.js +85 -0
  64. package/build/utils/pcm16.js.map +1 -0
  65. package/build/utils/softapTrace.d.ts +39 -0
  66. package/build/utils/softapTrace.d.ts.map +1 -0
  67. package/build/utils/softapTrace.js +124 -0
  68. package/build/utils/softapTrace.js.map +1 -0
  69. package/package.json +7 -7
  70. package/src/generated/releaseMetadata.ts +5 -5
  71. package/src/index.ts +8 -0
  72. package/src/react/MentraLiveOtaFlow.tsx +22 -3
  73. package/src/react/useMentraLiveOta.ts +44 -6
  74. package/src/services/AcsMeetingService.ts +956 -22
  75. package/src/services/AudioPlaybackService.ts +12 -3
  76. package/src/services/GlassesMicProbe.ts +300 -0
  77. package/src/services/LocalMiniappRuntime.ts +775 -42
  78. package/src/services/MentraJSLogPipeline.ts +70 -3
  79. package/src/services/MentraJSRouter.ts +2 -2
  80. package/src/services/MicStateCoordinator.ts +35 -3
  81. package/src/services/OtaErrorMapping.ts +77 -25
  82. package/src/services/OtaInstallCoordinator.ts +6 -1
  83. package/src/services/PhoneStreamCoordinator.ts +10 -0
  84. package/src/services/SoftapCallTransport.ts +928 -0
  85. package/src/services/SoftapCleanupBarrier.ts +72 -0
  86. package/src/utils/pcm16.ts +93 -0
  87. package/src/utils/softapTrace.ts +133 -0
@@ -81,7 +81,25 @@ import {resolveForegroundLocationPermission} from "./ForegroundLocationPermissio
81
81
  import {advanceMiniappPingLiveness} from "./MiniappLiveness"
82
82
  import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
83
83
  import {LocalMiniappStorage} from "./LocalMiniappStorage"
84
- import acsMeetingService, {parseAcsOutgoingVideo, resolveAcsAudioSource} from "./AcsMeetingService"
84
+ import acsMeetingService, {
85
+ parseAcsOutgoingVideo,
86
+ parseAcsVideoSource,
87
+ resolveAcsAudioSource,
88
+ type AcsOutgoingVideo,
89
+ type AcsVideoSource,
90
+ type MeetingState,
91
+ } from "./AcsMeetingService"
92
+ import {
93
+ createSoftapCallDeps,
94
+ SoftapCallError,
95
+ SoftapCallTransport,
96
+ SOFTAP_STEPS,
97
+ type SoftapProgress,
98
+ type SoftapTeardownMode,
99
+ } from "./SoftapCallTransport"
100
+ import {awaitCleanupBarrier} from "./SoftapCleanupBarrier"
101
+ import {softapTrace, softapTraceFailure} from "../utils/softapTrace"
102
+ import {PermissionFeatures, permissions} from "../facades/permissions"
85
103
 
86
104
  // =============================================================================
87
105
  // Types
@@ -168,23 +186,6 @@ function locationRateRank(rate: string | null | undefined): number {
168
186
  return i >= 0 ? i : LOCATION_RATE_PRIORITY.indexOf("passive")
169
187
  }
170
188
 
171
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
172
- return new Promise((resolve) => {
173
- let settled = false
174
- const done = (value: T | null) => {
175
- if (settled) return
176
- settled = true
177
- BgTimer.clearTimeout(timer)
178
- resolve(value)
179
- }
180
- const timer = BgTimer.setTimeout(() => done(null), timeoutMs)
181
- promise.then(
182
- (value) => done(value),
183
- () => done(null),
184
- )
185
- })
186
- }
187
-
188
189
  const LOG_TAG = "LOCAL_MINIAPP"
189
190
  const DIAGNOSTIC_MAX_LIST_ITEMS = 100
190
191
  const DIAGNOSTIC_MAX_STRING_LENGTH = 512
@@ -219,6 +220,14 @@ const MINIAPP_AUTH_REFRESH_MIN_DELAY_MS = 5_000
219
220
  const MINIAPP_AUTH_RETRY_BASE_MS = 1_000
220
221
  const MINIAPP_AUTH_RETRY_MAX_MS = 15_000
221
222
  const FOREGROUND_LIVENESS_PROBE_TIMEOUT_MS = 2_500
223
+ /**
224
+ * How long a SoftAP call waits for the first frame to reach ACS before failing the join.
225
+ *
226
+ * Generous because it covers the glasses' whole publish path — WHIP POST, host-only ICE, and the
227
+ * first decode — but bounded, since a call that connects and never paints has to fail as a join
228
+ * rather than sit in "connecting" while the wearer waits.
229
+ */
230
+ const SOFTAP_FIRST_FRAME_MS = 20_000
222
231
  const REQUEST_WIFI_SETUP_TYPE = "miniapp_request_wifi_setup"
223
232
  // Unregister after this many missed pongs. Generous on purpose: a busy
224
233
  // context (heavy interim translation traffic) or OS scheduling while idle can
@@ -374,6 +383,46 @@ function resetPermissionWarnings(packageName: string): void {
374
383
  }
375
384
  }
376
385
 
386
+ /**
387
+ * One attempt to start a SoftAP call, from the first permission check to the last release.
388
+ *
389
+ * The unit is the attempt rather than the transport because the transport only exists for the
390
+ * second half of it. Everything the wearer can cancel — the Nearby-devices prompt, the wait for
391
+ * mobile data, the ACS sign-in — happens before `transport.start()`, and each of those leaves
392
+ * something behind (a signed-in agent, a process-wide cellular pin) that only the attempt knows
393
+ * about.
394
+ */
395
+ type SoftapAttempt = {
396
+ id: number
397
+ packageName: string
398
+ startedAt: number
399
+ /** Set by leave, end, or a superseding join. Checked after every await the join performs. */
400
+ cancelled: boolean
401
+ /** Set only after the preceding attempt has fully settled. */
402
+ ownsResources: boolean
403
+ transport: SoftapCallTransport | null
404
+ /** The checklist as the miniapp last saw it; preflight rows live here until `start()` takes over. */
405
+ progress: SoftapProgress
406
+ scopedLostUnsub: (() => void) | null
407
+ /**
408
+ * The join itself, settled either way. A native call still in flight owns state the teardown
409
+ * cannot see — a `createCallAgent` mid-sign-in — so the barrier covers this as well.
410
+ */
411
+ body: Promise<void> | null
412
+ /** Single-flight teardown, so a leave and the join's own failure path cannot both unwind. */
413
+ teardown: Promise<void> | null
414
+ /** Opens only once the attempt *and* its cleanup are finished. The next join waits on it. */
415
+ settled: Promise<void>
416
+ settledDone: boolean
417
+ markSettled: () => void
418
+ }
419
+
420
+ /** How long a stalled SoftAP cleanup goes unremarked before it is logged. It is never abandoned. */
421
+ const SOFTAP_CLEANUP_STALL_LOG_MS = 10_000
422
+
423
+ /** How long the next call waits for the previous one's cleanup before telling the wearer about it. */
424
+ const SOFTAP_CLEANUP_NARRATE_AFTER_MS = 250
425
+
377
426
  // =============================================================================
378
427
  // LocalMiniappRuntime
379
428
  // =============================================================================
@@ -381,6 +430,24 @@ function resetPermissionWarnings(packageName: string): void {
381
430
  class LocalMiniappRuntime {
382
431
  private static instance: LocalMiniappRuntime | null = null
383
432
 
433
+ /**
434
+ * The SoftAP call being started or running, if any.
435
+ *
436
+ * An *attempt*, not a transport, because the cancellable part of a SoftAP call begins long
437
+ * before the transport exists: a Nearby-devices prompt and an ACS sign-in run first, and a
438
+ * wearer who taps Cancel during either of them has to be obeyed. The attempt is registered
439
+ * before the first `await` and is what every later stage checks itself against.
440
+ */
441
+ private softapAttempt: SoftapAttempt | null = null
442
+ private softapAttemptSeq = 0
443
+ /**
444
+ * Why the previous SoftAP call's cleanup failed, if it did.
445
+ *
446
+ * Read once by the next join, which refuses rather than building on leaked state: a hotspot that
447
+ * would not turn off is precisely what makes the next call fail somewhere far less legible.
448
+ */
449
+ private softapCleanupError: string | null = null
450
+
384
451
  /** Connected miniapps keyed by packageName. */
385
452
  private connectedApps: Map<string, ConnectedMiniapp> = new Map()
386
453
 
@@ -1327,6 +1394,9 @@ class LocalMiniappRuntime {
1327
1394
  case MiniappRequestType.MEETING_LEAVE:
1328
1395
  void this.handleMeetingLeave(packageName, requestId)
1329
1396
  break
1397
+ case MiniappRequestType.MEETING_END:
1398
+ void this.handleMeetingEnd(packageName, requestId)
1399
+ break
1330
1400
  case MiniappRequestType.MEETING_SET_MUTED:
1331
1401
  void this.handleMeetingSetMuted(packageName, payload, requestId)
1332
1402
  break
@@ -1397,7 +1467,7 @@ class LocalMiniappRuntime {
1397
1467
  // Register if not already
1398
1468
  const existing = this.connectedApps.get(packageName)
1399
1469
  if (!existing) {
1400
- console.warn(`${LOG_TAG}: CONNECT from unregistered app ${packageName}, ignoring`)
1470
+ console.warn(`${LOG_TAG}: CONNECT from unregistered app ${packageName}, dropping — isolate already torn down`)
1401
1471
  return
1402
1472
  }
1403
1473
 
@@ -1423,37 +1493,25 @@ class LocalMiniappRuntime {
1423
1493
  existing.authDelivered = false
1424
1494
  this.clearMiniappAuthDeliveryRetry(packageName)
1425
1495
  const authPromise = this.requestMiniappAuth(packageName)
1426
- const initialAuth = await withTimeout(authPromise, 1_500)
1427
- const userId = initialAuth?.mentraUserId ?? ""
1428
- if (initialAuth) {
1429
- existing.authDelivered = true
1430
- this.scheduleMiniappAuthRefresh(packageName, initialAuth)
1431
- }
1432
1496
 
1497
+ // Send CONNECT_ACK in this turn, before any await. A 1.5s auth wait let a
1498
+ // concurrent unregister (dev respawn, tray stop, glasses-not-ready teardown)
1499
+ // drop the ACK; Mentra Call then dies with CONNECT_ACK timeout even though
1500
+ // CONNECT landed. Auth still arrives on AUTH_UPDATE.
1433
1501
  this.sendToMiniapp(
1434
1502
  packageName,
1435
1503
  {
1436
1504
  type: MiniappResponseType.CONNECT_ACK,
1437
- userId,
1505
+ userId: "",
1438
1506
  packageName,
1439
1507
  capabilities,
1440
1508
  permissions: declaredPermissions,
1441
1509
  configuration: getMiniappConfiguration(packageName),
1442
1510
  hostFeatures: {captureAudio: true},
1443
- ...(initialAuth ? {auth: initialAuth} : {}),
1444
1511
  },
1445
1512
  requestId,
1446
1513
  )
1447
- if (!initialAuth) {
1448
- // The mint timed out or (more importantly) REJECTED — the latter happens
1449
- // when the cloud client hasn't finished its first-boot Core token exchange
1450
- // yet, which is exactly the case when a dev miniapp is scanned/launched
1451
- // right after app start. CONNECT_ACK already went out without auth; keep
1452
- // trying to mint (and re-drive on cloud-connect) so the app authenticates
1453
- // to its backend on its own, instead of staying dead until a full manual
1454
- // Cloud V2 reconnect re-runs the whole handshake.
1455
- this.deliverInitialMiniappAuth(packageName, authPromise, 0)
1456
- }
1514
+ this.deliverInitialMiniappAuth(packageName, authPromise, 0)
1457
1515
  this.sendCloudStatusToMiniapp(packageName)
1458
1516
 
1459
1517
  // Handshake complete — unblock any launcher.waitForConnect() callers.
@@ -3633,13 +3691,21 @@ class LocalMiniappRuntime {
3633
3691
  }
3634
3692
  const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : ""
3635
3693
  const token = typeof payload.token === "string" ? payload.token : ""
3636
- const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
3637
- const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : ""
3638
3694
  const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined
3639
- if (!meetingUrl || !token || !whepUrl) {
3695
+ if (!meetingUrl || !token) {
3640
3696
  this.sendResult(packageName, requestId, false, undefined, {
3641
3697
  code: MiniappErrorCode.INVALID_ARGUMENT,
3642
- message: "meetingUrl, token, and a WHEP videoSource are required",
3698
+ message: "meetingUrl and token are required",
3699
+ })
3700
+ return
3701
+ }
3702
+ let videoSource: AcsVideoSource
3703
+ try {
3704
+ videoSource = parseAcsVideoSource(payload.videoSource)
3705
+ } catch (error) {
3706
+ this.sendResult(packageName, requestId, false, undefined, {
3707
+ code: MiniappErrorCode.INVALID_ARGUMENT,
3708
+ message: error instanceof Error ? error.message : "Invalid meeting video source",
3643
3709
  })
3644
3710
  return
3645
3711
  }
@@ -3654,10 +3720,47 @@ class LocalMiniappRuntime {
3654
3720
  })
3655
3721
  return
3656
3722
  }
3723
+ // SoftAP is a sequence, not a single call: the hotspot and the scoped network have to exist
3724
+ // before the ACS join binds a listener, and the glasses can only be told where to publish
3725
+ // after that. The miniapp asks for the transport and the host owns the ordering.
3726
+ if (videoSource.type === "softap") {
3727
+ // The miniapp's `requestId` is the only identifier the two logs share until the attempt
3728
+ // exists, so it is what joins the miniapp's own join line to the host's.
3729
+ softapTrace("meeting_join_request", {
3730
+ packageName,
3731
+ requestId: requestId ?? "none",
3732
+ transport: videoSource.type,
3733
+ hasDisplayName: Boolean(displayName),
3734
+ video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
3735
+ })
3736
+ const startedAt = Date.now()
3737
+ try {
3738
+ const state = await this.joinSoftapMeeting(packageName, {meetingUrl, token, displayName, video})
3739
+ softapTrace("meeting_join_result", {
3740
+ packageName,
3741
+ requestId: requestId ?? "none",
3742
+ state: state.state,
3743
+ micTransport: state.micTransport ?? "unknown",
3744
+ durationMs: Date.now() - startedAt,
3745
+ })
3746
+ this.sendResult(packageName, requestId, true, state)
3747
+ } catch (error) {
3748
+ softapTraceFailure("meeting_join_result", {
3749
+ packageName,
3750
+ requestId: requestId ?? "none",
3751
+ step: error instanceof SoftapCallError ? error.step : "unknown",
3752
+ code: error instanceof SoftapCallError ? error.code : "INTERNAL",
3753
+ reason: error instanceof Error ? error.message : String(error),
3754
+ durationMs: Date.now() - startedAt,
3755
+ })
3756
+ throw error
3757
+ }
3758
+ return
3759
+ }
3657
3760
  const state = await acsMeetingService.join(packageName, {
3658
3761
  meetingUrl,
3659
3762
  token,
3660
- whepUrl,
3763
+ videoSource,
3661
3764
  displayName,
3662
3765
  ...(video ? {video} : {}),
3663
3766
  })
@@ -3670,11 +3773,583 @@ class LocalMiniappRuntime {
3670
3773
  }
3671
3774
  }
3672
3775
 
3776
+ /**
3777
+ * Runs the SoftAP call sequence and returns the meeting state it reached.
3778
+ *
3779
+ * Everything is hung off one [SoftapAttempt] registered before the first `await`, because the
3780
+ * cancellable part of this starts immediately and the transport only exists halfway through.
3781
+ * The attempt is also what `meeting.leave` unwinds: leaving only the ACS call would strand the
3782
+ * glasses publishing into a listener nobody reads, the phone on a hotspot it no longer needs,
3783
+ * and this process pinned to cellular.
3784
+ */
3785
+ private async joinSoftapMeeting(
3786
+ packageName: string,
3787
+ args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo},
3788
+ ): Promise<MeetingState> {
3789
+ // Reserve before awaiting retirement: a second Start or Cancel must see this request,
3790
+ // including while it is waiting for its predecessor's native cleanup.
3791
+ const previous = this.softapAttempt
3792
+ const attempt = this.createSoftapAttempt(packageName)
3793
+ this.softapAttempt = attempt
3794
+ const body = this.runSoftapAttempt(attempt, previous, args)
3795
+ // Settled, never rejected: this exists so the *next* attempt can tell when this one's native
3796
+ // work has actually stopped, and a rejection here is already handled below.
3797
+ attempt.body = body.then(
3798
+ () => undefined,
3799
+ () => undefined,
3800
+ )
3801
+ try {
3802
+ return await body
3803
+ } catch (error) {
3804
+ softapTraceFailure("softap_attempt_failed", {
3805
+ attempt: attempt.id,
3806
+ step: error instanceof SoftapCallError ? error.step : "unknown",
3807
+ code: error instanceof SoftapCallError ? error.code : "INTERNAL",
3808
+ reason: error instanceof Error ? error.message : String(error),
3809
+ attemptAgeMs: Date.now() - attempt.startedAt,
3810
+ })
3811
+ // Unwind whatever the attempt reached — including the preflight-owned agent and cellular
3812
+ // pin, which the transport never sees — before the failure surfaces.
3813
+ await this.retireSoftapAttempt({attempt}).catch(() => undefined)
3814
+ throw error
3815
+ }
3816
+ }
3817
+
3818
+ private createSoftapAttempt(packageName: string): SoftapAttempt {
3819
+ const id = ++this.softapAttemptSeq
3820
+ let resolveSettled!: () => void
3821
+ const settled = new Promise<void>((resolve) => {
3822
+ resolveSettled = resolve
3823
+ })
3824
+ const attempt: SoftapAttempt = {
3825
+ id,
3826
+ packageName,
3827
+ startedAt: Date.now(),
3828
+ cancelled: false,
3829
+ ownsResources: false,
3830
+ transport: null,
3831
+ progress: {
3832
+ traceId: `preflight-${id}`,
3833
+ phase: "starting",
3834
+ steps: SOFTAP_STEPS.map((step) => ({step, status: "pending" as const})),
3835
+ elapsedMs: 0,
3836
+ },
3837
+ scopedLostUnsub: null,
3838
+ body: null,
3839
+ teardown: null,
3840
+ settled,
3841
+ settledDone: false,
3842
+ markSettled: () => {
3843
+ if (attempt.settledDone) return
3844
+ attempt.settledDone = true
3845
+ softapTrace("softap_attempt_settled", {attempt: id, attemptAgeMs: Date.now() - attempt.startedAt})
3846
+ resolveSettled()
3847
+ },
3848
+ }
3849
+ softapTrace("softap_attempt_created", {attempt: id, packageName})
3850
+ return attempt
3851
+ }
3852
+
3853
+ /**
3854
+ * Throws if this attempt has been cancelled or superseded.
3855
+ *
3856
+ * Called after every `await` in the join. The wearer's Cancel cannot interrupt a native call
3857
+ * that is already in flight, so the next best thing — and the thing that actually makes Cancel
3858
+ * work — is to refuse to take another step once they have asked.
3859
+ *
3860
+ * @param after the await this checkpoint follows. The whole join surfaces as one CANCELLED
3861
+ * error, so without a name in the log there is no way to tell a Cancel during the permission
3862
+ * prompt from one during a 35-second Teams sign-in.
3863
+ */
3864
+ private checkpointSoftapAttempt(attempt: SoftapAttempt, after: string): void {
3865
+ if (attempt.cancelled || this.softapAttempt !== attempt) {
3866
+ softapTraceFailure("softap_attempt_cancelled", {
3867
+ attempt: attempt.id,
3868
+ after,
3869
+ attemptAgeMs: Date.now() - attempt.startedAt,
3870
+ // Two different causes with one outcome: the wearer asked to leave, or a second join
3871
+ // took the slot. They need different fixes, so they are not collapsed here.
3872
+ cause: attempt.cancelled ? "retired" : "superseded",
3873
+ current: this.softapAttempt?.id ?? "none",
3874
+ })
3875
+ throw new SoftapCallError("hotspot", "CANCELLED", "The call was cancelled while it was starting")
3876
+ }
3877
+ }
3878
+
3879
+ /** Publishes a checklist snapshot, unless this attempt has already been superseded. */
3880
+ private emitSoftapProgress(attempt: SoftapAttempt, progress: SoftapProgress): void {
3881
+ if (this.softapAttempt !== attempt || attempt.cancelled) {
3882
+ // A dropped checklist is why a stalled join used to look like a stuck UI: the wearer's last
3883
+ // row was written by an attempt that no longer owned the call, and nothing said so.
3884
+ softapTrace("softap_progress_dropped", {
3885
+ attempt: attempt.id,
3886
+ phase: progress.phase,
3887
+ traceId: progress.traceId,
3888
+ cause: attempt.cancelled ? "retired" : "superseded",
3889
+ current: this.softapAttempt?.id ?? "none",
3890
+ })
3891
+ return
3892
+ }
3893
+ attempt.progress = progress
3894
+ const current = acsMeetingService.getState()
3895
+ this.sendToMiniapp(attempt.packageName, {
3896
+ type: MiniappResponseType.MEETING_STATE,
3897
+ ...current,
3898
+ // Before the ACS join there is no native state yet, so the phase reads `connecting`: the
3899
+ // join *is* in progress, and `idle` would make the miniapp think the call ended.
3900
+ state: current.state === "idle" ? "connecting" : current.state,
3901
+ softap: progress,
3902
+ })
3903
+ }
3904
+
3905
+ /**
3906
+ * Narrates preflight work on the first row while it is still pending.
3907
+ *
3908
+ * Permissions, mobile data, and the ACS sign-in take most of a minute between them and happen
3909
+ * before the transport exists, so without this the wearer watches five pending rows and
3910
+ * concludes the app has hung. No new step and no protocol change: it is the `hotspot` row's
3911
+ * detail, which the connecting screen renders as its one sub-line until step 1 starts.
3912
+ */
3913
+ private narrateSoftapPreflight(attempt: SoftapAttempt, detail: string): void {
3914
+ this.emitSoftapProgress(attempt, {
3915
+ ...attempt.progress,
3916
+ elapsedMs: Date.now() - attempt.startedAt,
3917
+ steps: attempt.progress.steps.map((step) => (step.step === "hotspot" ? {...step, detail} : step)),
3918
+ })
3919
+ }
3920
+
3921
+ private async runSoftapAttempt(
3922
+ attempt: SoftapAttempt,
3923
+ previous: SoftapAttempt | null,
3924
+ args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo},
3925
+ ): Promise<MeetingState> {
3926
+ const packageName = attempt.packageName
3927
+ if (previous) {
3928
+ softapTrace("softap_join_superseding", {
3929
+ previousAttempt: previous.id,
3930
+ previousAgeMs: Date.now() - previous.startedAt,
3931
+ previousPhase: previous.progress.phase,
3932
+ packageName,
3933
+ })
3934
+ await this.retireSoftapAttempt({attempt: previous}).catch(() => undefined)
3935
+ await awaitCleanupBarrier({
3936
+ settled: previous.settled,
3937
+ settledDone: previous.settledDone,
3938
+ narrate: (detail) => this.narrateSoftapPreflight(attempt, detail),
3939
+ narrateAfterMs: SOFTAP_CLEANUP_NARRATE_AFTER_MS,
3940
+ attempt: attempt.id,
3941
+ waitingFor: `attempt ${previous.id} teardown`,
3942
+ })
3943
+ }
3944
+ this.checkpointSoftapAttempt(attempt, "cleanup barrier")
3945
+ const cleanupError = this.softapCleanupError
3946
+ if (cleanupError) {
3947
+ // Report once, allowing a retry after the wearer power-cycles the glasses.
3948
+ this.softapCleanupError = null
3949
+ softapTraceFailure("softap_join_refused", {packageName, reason: cleanupError})
3950
+ throw new Error(`Previous call cleanup failed: ${cleanupError}. Power-cycle the glasses hotspot and try again.`)
3951
+ }
3952
+ attempt.ownsResources = true
3953
+ this.narrateSoftapPreflight(attempt, "Checking the Nearby devices permission…")
3954
+ const permissionStartedAt = Date.now()
3955
+ if (!(await permissions.check(PermissionFeatures.LOCAL_WIFI))) {
3956
+ this.checkpointSoftapAttempt(attempt, "permission check")
3957
+ softapTrace("softap_permission_prompt", {attempt: attempt.id, permission: "LOCAL_WIFI"})
3958
+ const granted = await permissions.request(PermissionFeatures.LOCAL_WIFI)
3959
+ this.checkpointSoftapAttempt(attempt, "permission prompt")
3960
+ softapTrace("softap_permission_result", {
3961
+ attempt: attempt.id,
3962
+ granted,
3963
+ // The prompt is modal, so this is the wearer's own reaction time and the single largest
3964
+ // unexplained gap in a first-run join.
3965
+ durationMs: Date.now() - permissionStartedAt,
3966
+ })
3967
+ if (!granted) {
3968
+ throw new Error("Nearby devices permission is required to join the glasses hotspot")
3969
+ }
3970
+ }
3971
+ this.checkpointSoftapAttempt(attempt, "permissions")
3972
+ const transport = new SoftapCallTransport(
3973
+ createSoftapCallDeps({
3974
+ packageName,
3975
+ meetingUrl: args.meetingUrl,
3976
+ token: args.token,
3977
+ displayName: args.displayName,
3978
+ awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
3979
+ subsystems: {
3980
+ setHotspotState: async (enabled) => {
3981
+ const status = await BluetoothSdk.setHotspotState(enabled)
3982
+ if (status.state === "enabled") {
3983
+ return {state: status.state, ssid: status.ssid, password: status.password}
3984
+ }
3985
+ return {state: status.state}
3986
+ },
3987
+ joinScopedNetwork: (ssid, passphrase) => acsMeetingService.joinScopedNetwork(ssid, passphrase),
3988
+ leaveScopedNetwork: () => acsMeetingService.leaveScopedNetwork(),
3989
+ probeGateway: async () => {
3990
+ const verdict = await acsMeetingService.probeScopedGateway()
3991
+ // No native probe means no verdict, and the orchestrator must not read that as "down".
3992
+ return verdict ?? {reachable: true, detail: "probe unsupported on this host"}
3993
+ },
3994
+ awaitValidatedDefaultNetwork: () => acsMeetingService.awaitValidatedDefaultNetwork(),
3995
+ onGlassesStreamStatus: (listener) => {
3996
+ const sub = BluetoothSdk.addListener("stream_status", (event) => {
3997
+ listener({
3998
+ status: event.status,
3999
+ streamId: event.streamId,
4000
+ reason: "reason" in event && typeof event.reason === "string" ? event.reason : undefined,
4001
+ error: "error" in event && typeof event.error === "string" ? event.error : undefined,
4002
+ })
4003
+ })
4004
+ return () => sub.remove()
4005
+ },
4006
+ joinMeeting: (pkg, options) =>
4007
+ acsMeetingService.join(pkg, {
4008
+ meetingUrl: options.meetingUrl,
4009
+ token: options.token,
4010
+ videoSource: options.videoSource,
4011
+ displayName: options.displayName,
4012
+ ...(args.video ? {video: args.video} : {}),
4013
+ }),
4014
+ leaveMeeting: (pkg) => acsMeetingService.leave(pkg),
4015
+ endMeeting: async (pkg) => {
4016
+ await acsMeetingService.endForEveryone(pkg)
4017
+ },
4018
+ ingestUrl: () => acsMeetingService.softApIngestUrl(),
4019
+ glassesLc3Uplink: () => acsMeetingService.glassesLc3UplinkActive(),
4020
+ startPublishing: (pkg, options) => phoneStreamCoordinator.startUnmanaged(pkg, options),
4021
+ stopPublishing: (pkg) => phoneStreamCoordinator.stop(pkg),
4022
+ },
4023
+ }),
4024
+ )
4025
+ attempt.transport = transport
4026
+ // A hotspot that vanishes mid-call is a real failure and a normal teardown is not, so both
4027
+ // guards are checked: the attempt must still be the current one (a stale event from a finished
4028
+ // call must never touch the next one) and it must not already be terminating. The unsubscribe
4029
+ // lives on the attempt, so retiring attempt N cannot clear attempt N+1's listener. Teardown
4030
+ // itself is left to the miniapp's terminal path, which calls `meeting.leave` — one owner.
4031
+ attempt.scopedLostUnsub = acsMeetingService.onScopedNetworkLost((error) => {
4032
+ if (this.softapAttempt !== attempt || attempt.cancelled) {
4033
+ console.log("[LocalMiniappRuntime] scoped loss for a superseded SoftAP call; ignoring", error)
4034
+ softapTrace("softap_scoped_loss_dropped", {
4035
+ attempt: attempt.id,
4036
+ code: error.code,
4037
+ cause: attempt.cancelled ? "retired" : "superseded",
4038
+ current: this.softapAttempt?.id ?? "none",
4039
+ })
4040
+ return
4041
+ }
4042
+ if (transport.isTerminating()) {
4043
+ console.log("[LocalMiniappRuntime] scoped loss during teardown; expected", error)
4044
+ softapTrace("softap_scoped_loss_dropped", {attempt: attempt.id, code: error.code, cause: "terminating"})
4045
+ return
4046
+ }
4047
+ console.warn("[LocalMiniappRuntime] SoftAP hotspot lost mid-call", error)
4048
+ softapTraceFailure("softap_scoped_loss_reported", {
4049
+ attempt: attempt.id,
4050
+ code: error.code,
4051
+ reason: error.message,
4052
+ attemptAgeMs: Date.now() - attempt.startedAt,
4053
+ })
4054
+ const current = acsMeetingService.getState()
4055
+ this.sendToMiniapp(packageName, {
4056
+ type: MiniappResponseType.MEETING_STATE,
4057
+ ...current,
4058
+ state: "error",
4059
+ error: `SOFTAP_NETWORK_LOST: ${error.message}`,
4060
+ softap: transport.progress(),
4061
+ })
4062
+ })
4063
+
4064
+ // Sign in on the phone's current internet *before* the glasses hotspot takes DNS. On device,
4065
+ // createCallAgent after the scoped join timed out at 20s and only finished once SoftAP was
4066
+ // released. It also waits for validated mobile data, which is why the wearer is told one
4067
+ // honest thing about the whole window rather than nothing at all.
4068
+ this.narrateSoftapPreflight(attempt, "Signing in to Teams…")
4069
+ const prepareStartedAt = Date.now()
4070
+ softapTrace("softap_prepare_agent_begin", {attempt: attempt.id})
4071
+ try {
4072
+ await acsMeetingService.prepareAgent({token: args.token, displayName: args.displayName})
4073
+ } catch (error) {
4074
+ softapTraceFailure("softap_prepare_agent_failed", {
4075
+ attempt: attempt.id,
4076
+ reason: error instanceof Error ? error.message : String(error),
4077
+ durationMs: Date.now() - prepareStartedAt,
4078
+ })
4079
+ throw error
4080
+ }
4081
+ softapTrace("softap_prepare_agent_done", {attempt: attempt.id, durationMs: Date.now() - prepareStartedAt})
4082
+ this.checkpointSoftapAttempt(attempt, "prepareAgent")
4083
+ await transport.start({
4084
+ // What the wearer has been reading for the last minute, so the first snapshot does not
4085
+ // blank it.
4086
+ initialSteps: attempt.progress.steps,
4087
+ // Every transition reaches the miniapp as a meeting-state event carrying the checklist.
4088
+ onProgress: (progress) => this.emitSoftapProgress(attempt, progress),
4089
+ })
4090
+ this.checkpointSoftapAttempt(attempt, "transport.start")
4091
+ const state = acsMeetingService.getState()
4092
+ softapTrace("softap_attempt_live", {
4093
+ attempt: attempt.id,
4094
+ state: state.state,
4095
+ micTransport: state.micTransport ?? "unknown",
4096
+ attemptAgeMs: Date.now() - attempt.startedAt,
4097
+ })
4098
+ return state
4099
+ }
4100
+
4101
+ /**
4102
+ * Cancels a SoftAP attempt and does not return until everything it owns has been released.
4103
+ *
4104
+ * This is the barrier the whole design rests on: "leave returned" has to mean "nothing from that
4105
+ * call is still in flight", or the next Start races the previous call's cleanup through the same
4106
+ * hotspot. There is deliberately no deadline — a native call that never returns holds the next
4107
+ * call back rather than letting it race — and a watchdog only says so in the log.
4108
+ *
4109
+ * Safe to call when there is no attempt, and safe to call twice: the teardown is single-flight
4110
+ * per attempt, so a leave and the join's own failure path share one unwind.
4111
+ */
4112
+ private async retireSoftapAttempt(options: {attempt?: SoftapAttempt; mode?: SoftapTeardownMode} = {}): Promise<void> {
4113
+ const attempt = options.attempt ?? this.softapAttempt
4114
+ if (!attempt) {
4115
+ // Not an error — every terminal path calls this without checking — but a Leave that found
4116
+ // nothing to retire and a Leave that tore a call down are very different log entries.
4117
+ softapTrace("softap_retire_noop", {mode: options.mode ?? "leave"})
4118
+ return
4119
+ }
4120
+ attempt.cancelled = true
4121
+ // Keep the retiring attempt discoverable until both its join and cleanup settle.
4122
+ // A newer reservation replaces it immediately, but must still wait on its barrier.
4123
+ softapTrace("softap_retire_begin", {
4124
+ attempt: attempt.id,
4125
+ mode: options.mode ?? "leave",
4126
+ attemptAgeMs: Date.now() - attempt.startedAt,
4127
+ phase: attempt.progress.phase,
4128
+ // Second and later callers join the first one's teardown rather than running their own, and
4129
+ // that sharing is what makes a leave racing a failed join safe.
4130
+ teardownAlreadyRunning: Boolean(attempt.teardown),
4131
+ })
4132
+ if (!attempt.teardown) {
4133
+ attempt.teardown = this.teardownSoftapAttempt(attempt, options.mode)
4134
+ // The barrier closes over the join body as well as the teardown, and opens only when both
4135
+ // have stopped. A watchdog reports a cleanup that is taking too long; it never declares one
4136
+ // finished, because "probably done by now" is exactly the assumption that broke restarts.
4137
+ const stallWatchdog = setTimeout(() => {
4138
+ console.warn("[LocalMiniappRuntime] softap_cleanup_stalled", {
4139
+ attempt: attempt.id,
4140
+ afterMs: SOFTAP_CLEANUP_STALL_LOG_MS,
4141
+ })
4142
+ }, SOFTAP_CLEANUP_STALL_LOG_MS)
4143
+ void Promise.allSettled([attempt.teardown, attempt.body ?? Promise.resolve()]).then(() => {
4144
+ clearTimeout(stallWatchdog)
4145
+ attempt.markSettled()
4146
+ if (this.softapAttempt === attempt) this.softapAttempt = null
4147
+ })
4148
+ }
4149
+ const awaitedFrom = Date.now()
4150
+ try {
4151
+ await attempt.teardown
4152
+ } catch (error) {
4153
+ // Only a refused End reaches here; the local unwind has completed either way, which is the
4154
+ // distinction the wearer's copy depends on.
4155
+ softapTraceFailure("softap_retire_done", {
4156
+ attempt: attempt.id,
4157
+ waitedMs: Date.now() - awaitedFrom,
4158
+ reason: error instanceof Error ? error.message : String(error),
4159
+ })
4160
+ throw error
4161
+ }
4162
+ softapTrace("softap_retire_done", {attempt: attempt.id, waitedMs: Date.now() - awaitedFrom})
4163
+ }
4164
+
4165
+ /**
4166
+ * Releases everything one attempt could own, in the reverse of the order it was taken.
4167
+ *
4168
+ * The scoped network and the ACS agent are released unconditionally, not only when the transport
4169
+ * built them: `prepareAgent` signs in and pins this whole process to cellular *before* the
4170
+ * transport exists, so a Cancel during sign-in would otherwise leave both behind. Both releases
4171
+ * are idempotent.
4172
+ *
4173
+ * Failures are collected rather than swallowed. Reporting a clean teardown that did not happen
4174
+ * is what lets the next call start on leaked state.
4175
+ */
4176
+ private async teardownSoftapAttempt(attempt: SoftapAttempt, mode?: SoftapTeardownMode): Promise<void> {
4177
+ // A cancelled reservation waiting behind another call never acquired any shared resources.
4178
+ // Its cleanup must not release the preceding call's native agent or network.
4179
+ if (!attempt.ownsResources) return
4180
+ // Terminal intent before the first release, so the scoped network we are about to drop cannot
4181
+ // be reported as a hotspot that walked away.
4182
+ acsMeetingService.beginScopedTeardown()
4183
+ const failures: string[] = []
4184
+ let endFailure: unknown = null
4185
+ const teardownStartedAt = Date.now()
4186
+ softapTrace("softap_teardown_begin", {
4187
+ attempt: attempt.id,
4188
+ mode: mode ?? "leave",
4189
+ hasTransport: Boolean(attempt.transport),
4190
+ steps: attempt.transport?.activeSteps().join(",") ?? "",
4191
+ })
4192
+ /**
4193
+ * One line per resource, whether it released or not.
4194
+ *
4195
+ * The releases below are sequential and every one of them can block on a native call, so a
4196
+ * teardown that takes half a minute is only readable if each step says how long it took. A
4197
+ * step that never reports is the one that hung.
4198
+ */
4199
+ const releaseStep = async (step: string, release: () => Promise<void>): Promise<unknown> => {
4200
+ const startedAt = Date.now()
4201
+ try {
4202
+ await release()
4203
+ softapTrace("softap_teardown_step", {
4204
+ attempt: attempt.id,
4205
+ step,
4206
+ outcome: "ok",
4207
+ durationMs: Date.now() - startedAt,
4208
+ })
4209
+ return null
4210
+ } catch (error) {
4211
+ softapTraceFailure("softap_teardown_step", {
4212
+ attempt: attempt.id,
4213
+ step,
4214
+ outcome: "failed",
4215
+ durationMs: Date.now() - startedAt,
4216
+ reason: error instanceof Error ? error.message : String(error),
4217
+ })
4218
+ return error
4219
+ }
4220
+ }
4221
+ const transport = attempt.transport
4222
+ if (transport) {
4223
+ // The only thing `stop()` throws is a refused End — the local teardown still completed,
4224
+ // so this is the caller's business, not a reason to block the next call.
4225
+ endFailure = await releaseStep("transport.stop", () => transport.stop(mode ? {mode} : {}))
4226
+ const undoFailures = transport.lastTeardownFailures()
4227
+ if (undoFailures.length > 0) failures.push(`could not release ${undoFailures.join(", ")}`)
4228
+ }
4229
+ const scopedFailure = await releaseStep("leaveScopedNetwork", () => acsMeetingService.leaveScopedNetwork())
4230
+ if (scopedFailure) {
4231
+ failures.push(
4232
+ `leaveScopedNetwork: ${scopedFailure instanceof Error ? scopedFailure.message : String(scopedFailure)}`,
4233
+ )
4234
+ }
4235
+ // Stay pinned across this wait. Unpinning onto a leftover glasses AP is what made the next
4236
+ // Start Call's `teams:create` return `Request failed (503)` — Cloudflare, no internet, not a
4237
+ // Teams configuration problem.
4238
+ const defaultNetworkStartedAt = Date.now()
4239
+ try {
4240
+ const network = await acsMeetingService.awaitValidatedDefaultNetwork()
4241
+ softapTrace("softap_teardown_step", {
4242
+ attempt: attempt.id,
4243
+ step: "awaitValidatedDefaultNetwork",
4244
+ outcome: "ok",
4245
+ durationMs: Date.now() - defaultNetworkStartedAt,
4246
+ // Null is "this host cannot tell", which is a different fact from an unusable route, and
4247
+ // the cellular pin is dropped on the strength of this answer.
4248
+ network: network?.detail ?? "unknown",
4249
+ usable: network?.usable ?? "unknown",
4250
+ })
4251
+ } catch (error) {
4252
+ console.warn("[LocalMiniappRuntime] default network after SoftAP teardown is unverified", {
4253
+ attempt: attempt.id,
4254
+ error: error instanceof Error ? error.message : String(error),
4255
+ })
4256
+ softapTraceFailure("softap_teardown_step", {
4257
+ attempt: attempt.id,
4258
+ step: "awaitValidatedDefaultNetwork",
4259
+ outcome: "failed",
4260
+ durationMs: Date.now() - defaultNetworkStartedAt,
4261
+ reason: error instanceof Error ? error.message : String(error),
4262
+ })
4263
+ }
4264
+ const acsLeaveStartedAt = Date.now()
4265
+ try {
4266
+ const outcome = await acsMeetingService.leaveAndAwait(attempt.packageName)
4267
+ softapTrace("softap_teardown_step", {
4268
+ attempt: attempt.id,
4269
+ step: "acsLeaveAndAwait",
4270
+ // `completed=false` is not a failure and is not recorded as one: the build simply cannot
4271
+ // confirm its own cleanup, so the next call's barrier is the only guarantee left.
4272
+ outcome: outcome.completed ? "ok" : "unconfirmed",
4273
+ reason: outcome.reason ?? "",
4274
+ durationMs: Date.now() - acsLeaveStartedAt,
4275
+ })
4276
+ if (!outcome.completed) {
4277
+ console.warn("[LocalMiniappRuntime] ACS cleanup completion is unverified on this build", {
4278
+ attempt: attempt.id,
4279
+ reason: outcome.reason,
4280
+ })
4281
+ }
4282
+ } catch (error) {
4283
+ softapTraceFailure("softap_teardown_step", {
4284
+ attempt: attempt.id,
4285
+ step: "acsLeaveAndAwait",
4286
+ outcome: "failed",
4287
+ durationMs: Date.now() - acsLeaveStartedAt,
4288
+ reason: error instanceof Error ? error.message : String(error),
4289
+ })
4290
+ failures.push(`ACS leave: ${error instanceof Error ? error.message : String(error)}`)
4291
+ }
4292
+ const hadScopedLostListener = Boolean(attempt.scopedLostUnsub)
4293
+ attempt.scopedLostUnsub?.()
4294
+ attempt.scopedLostUnsub = null
4295
+ softapTrace("softap_teardown_step", {
4296
+ attempt: attempt.id,
4297
+ step: "scopedLostListener",
4298
+ outcome: hadScopedLostListener ? "ok" : "not taken",
4299
+ })
4300
+ if (failures.length > 0) {
4301
+ this.softapCleanupError = failures.join("; ")
4302
+ console.warn("[LocalMiniappRuntime] softap_cleanup_failed", {attempt: attempt.id, failures})
4303
+ }
4304
+ // The recorded failures are what the *next* join refuses on, so they are logged as the list
4305
+ // that will be quoted back to the wearer rather than only as the individual step failures.
4306
+ softapTrace("softap_teardown_done", {
4307
+ attempt: attempt.id,
4308
+ durationMs: Date.now() - teardownStartedAt,
4309
+ refusesNextCall: failures.length > 0,
4310
+ failures: failures.join("; "),
4311
+ endRefused: Boolean(endFailure),
4312
+ })
4313
+ if (endFailure) throw endFailure
4314
+ }
4315
+
3673
4316
  private async handleMeetingLeave(packageName: string, requestId?: string): Promise<void> {
4317
+ const startedAt = Date.now()
4318
+ softapTrace("meeting_leave_request", {
4319
+ packageName,
4320
+ requestId: requestId ?? "none",
4321
+ attempt: this.softapAttempt?.id ?? "none",
4322
+ })
3674
4323
  try {
4324
+ // A SoftAP call owns the hotspot, the scoped network, and the cellular pin as well as the
4325
+ // ACS call, and retiring the attempt releases all of them. Returning before that finishes is
4326
+ // what let the next Start race this teardown.
4327
+ if (this.softapAttempt) {
4328
+ await this.retireSoftapAttempt()
4329
+ softapTrace("meeting_leave_result", {
4330
+ packageName,
4331
+ requestId: requestId ?? "none",
4332
+ path: "softap",
4333
+ durationMs: Date.now() - startedAt,
4334
+ })
4335
+ this.sendResult(packageName, requestId, true)
4336
+ return
4337
+ }
3675
4338
  await acsMeetingService.leave(packageName)
4339
+ softapTrace("meeting_leave_result", {
4340
+ packageName,
4341
+ requestId: requestId ?? "none",
4342
+ path: "acs",
4343
+ durationMs: Date.now() - startedAt,
4344
+ })
3676
4345
  this.sendResult(packageName, requestId, true)
3677
4346
  } catch (err) {
4347
+ softapTraceFailure("meeting_leave_result", {
4348
+ packageName,
4349
+ requestId: requestId ?? "none",
4350
+ reason: err instanceof Error ? err.message : String(err),
4351
+ durationMs: Date.now() - startedAt,
4352
+ })
3678
4353
  this.sendResult(packageName, requestId, false, undefined, {
3679
4354
  code: MiniappErrorCode.INTERNAL,
3680
4355
  message: err instanceof Error ? err.message : "ACS meeting leave failed",
@@ -3682,15 +4357,73 @@ class LocalMiniappRuntime {
3682
4357
  }
3683
4358
  }
3684
4359
 
4360
+ /**
4361
+ * End the meeting for everyone, then tear this device down.
4362
+ *
4363
+ * A rejection here always means "the others may still be in the meeting", never "you are still in
4364
+ * it": both paths below complete local teardown before the error surfaces.
4365
+ */
4366
+ private async handleMeetingEnd(packageName: string, requestId?: string): Promise<void> {
4367
+ const startedAt = Date.now()
4368
+ const softap = Boolean(this.softapAttempt)
4369
+ softapTrace("meeting_end_request", {
4370
+ packageName,
4371
+ requestId: requestId ?? "none",
4372
+ attempt: this.softapAttempt?.id ?? "none",
4373
+ })
4374
+ try {
4375
+ if (this.softapAttempt) {
4376
+ await this.retireSoftapAttempt({mode: "end"})
4377
+ } else {
4378
+ await acsMeetingService.endForEveryone(packageName)
4379
+ }
4380
+ softapTrace("meeting_end_result", {
4381
+ packageName,
4382
+ requestId: requestId ?? "none",
4383
+ path: softap ? "softap" : "acs",
4384
+ durationMs: Date.now() - startedAt,
4385
+ })
4386
+ this.sendResult(packageName, requestId, true)
4387
+ } catch (err) {
4388
+ // A rejection here always means the others may still be in the meeting, never that this
4389
+ // device is — the log has to be readable that way round or it accuses the wrong side.
4390
+ softapTraceFailure("meeting_end_result", {
4391
+ packageName,
4392
+ requestId: requestId ?? "none",
4393
+ reason: err instanceof Error ? err.message : String(err),
4394
+ durationMs: Date.now() - startedAt,
4395
+ })
4396
+ this.sendResult(packageName, requestId, false, undefined, {
4397
+ code: MiniappErrorCode.INTERNAL,
4398
+ message: err instanceof Error ? err.message : "Could not end the meeting for everyone",
4399
+ })
4400
+ }
4401
+ }
4402
+
3685
4403
  private async handleMeetingSetMuted(
3686
4404
  packageName: string,
3687
4405
  payload: Record<string, unknown>,
3688
4406
  requestId?: string,
3689
4407
  ): Promise<void> {
4408
+ const startedAt = Date.now()
3690
4409
  try {
3691
4410
  const state = await acsMeetingService.setMuted(packageName, Boolean(payload.muted))
4411
+ softapTrace("meeting_set_muted", {
4412
+ packageName,
4413
+ requestId: requestId ?? "none",
4414
+ requested: Boolean(payload.muted),
4415
+ muted: state.muted,
4416
+ durationMs: Date.now() - startedAt,
4417
+ })
3692
4418
  this.sendResult(packageName, requestId, true, state)
3693
4419
  } catch (err) {
4420
+ softapTraceFailure("meeting_set_muted", {
4421
+ packageName,
4422
+ requestId: requestId ?? "none",
4423
+ requested: Boolean(payload.muted),
4424
+ reason: err instanceof Error ? err.message : String(err),
4425
+ durationMs: Date.now() - startedAt,
4426
+ })
3694
4427
  this.sendResult(packageName, requestId, false, undefined, {
3695
4428
  code: MiniappErrorCode.INTERNAL,
3696
4429
  message: err instanceof Error ? err.message : "ACS mute failed",