@mentra/engine 3.2.0-dev.235 → 3.2.0-dev.247

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 (44) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +70 -0
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +97 -0
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/AppRegistry.d.ts +2 -12
  8. package/build/services/AppRegistry.d.ts.map +1 -1
  9. package/build/services/AppRegistry.js +2 -37
  10. package/build/services/AppRegistry.js.map +1 -1
  11. package/build/services/CloudClientService.d.ts +1 -1
  12. package/build/services/CloudClientService.d.ts.map +1 -1
  13. package/build/services/CloudClientService.js +2 -2
  14. package/build/services/CloudClientService.js.map +1 -1
  15. package/build/services/LocalMiniappRuntime.d.ts +51 -0
  16. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  17. package/build/services/LocalMiniappRuntime.js +228 -6
  18. package/build/services/LocalMiniappRuntime.js.map +1 -1
  19. package/build/services/PhoneNotificationsSync.d.ts +1 -1
  20. package/build/services/PhonePhotoCoordinator.d.ts +3 -2
  21. package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
  22. package/build/services/PhonePhotoCoordinator.js +19 -14
  23. package/build/services/PhonePhotoCoordinator.js.map +1 -1
  24. package/build/services/SoftapCallTransport.d.ts +64 -0
  25. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  26. package/build/services/SoftapCallTransport.js +103 -3
  27. package/build/services/SoftapCallTransport.js.map +1 -1
  28. package/build/services/manifestPermissions.d.ts +13 -0
  29. package/build/services/manifestPermissions.d.ts.map +1 -0
  30. package/build/services/manifestPermissions.js +42 -0
  31. package/build/services/manifestPermissions.js.map +1 -0
  32. package/build/types/applet.d.ts +13 -1
  33. package/build/types/applet.d.ts.map +1 -1
  34. package/build/types/applet.js.map +1 -1
  35. package/package.json +8 -8
  36. package/src/generated/releaseMetadata.ts +5 -5
  37. package/src/services/AcsMeetingService.ts +138 -0
  38. package/src/services/AppRegistry.ts +3 -40
  39. package/src/services/CloudClientService.ts +2 -2
  40. package/src/services/LocalMiniappRuntime.ts +268 -7
  41. package/src/services/PhonePhotoCoordinator.ts +18 -14
  42. package/src/services/SoftapCallTransport.ts +151 -16
  43. package/src/services/manifestPermissions.ts +44 -0
  44. package/src/types/applet.ts +11 -0
@@ -83,6 +83,23 @@ export function parseMeetingCapabilities(raw: unknown): MeetingCapabilities | un
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * Read the ACS `CallEndReason` off a native state event.
88
+ *
89
+ * Native flattens it into `endReason_code` / `endReason_subcode` / `endReason_message` rather than
90
+ * a nested object, because the Expo bridge drops nested nulls. Anything non-numeric is discarded:
91
+ * a code that arrived as a string would compare unequal to every entry in the lookup tables and
92
+ * classify a known failure as unknown, which is worse than having no code at all.
93
+ */
94
+ export function parseMeetingEndReason(event: Record<string, unknown>): MeetingEndReason | undefined {
95
+ const num = (value: unknown): number | undefined => (typeof value === "number" && Number.isFinite(value) ? value : undefined)
96
+ const code = num(event.endReason_code)
97
+ const subcode = num(event.endReason_subcode)
98
+ const message = typeof event.endReason_message === "string" ? event.endReason_message : undefined
99
+ if (code === undefined && subcode === undefined && !message) return undefined
100
+ return {...(code !== undefined ? {code} : {}), ...(subcode !== undefined ? {subcode} : {}), ...(message ? {message} : {})}
101
+ }
102
+
86
103
  export interface MeetingState {
87
104
  state: MeetingPhase
88
105
  muted: boolean
@@ -107,6 +124,22 @@ export interface MeetingState {
107
124
  * so a consumer keeps the last one it saw rather than treating its absence as a reset.
108
125
  */
109
126
  softap?: SoftapProgress
127
+ /**
128
+ * ACS `CallEndReason`, forwarded numerically.
129
+ *
130
+ * It is the only machine-readable statement of *why* a join failed, and a miniapp cannot tell a
131
+ * dead meeting link from a dropped network any other way — the human message is localised and
132
+ * reworded between SDK releases, so matching on it would turn an outage into "your link is
133
+ * broken" the first time Microsoft rephrases a sentence.
134
+ */
135
+ endReason?: MeetingEndReason
136
+ }
137
+
138
+ /** Numeric ACS `CallEndReason`. `message` is for logs and bug reports only, never for branching. */
139
+ export interface MeetingEndReason {
140
+ code?: number
141
+ subcode?: number
142
+ message?: string
110
143
  }
111
144
 
112
145
  /**
@@ -291,6 +324,19 @@ export interface DefaultNetworkStatus {
291
324
  detail: string
292
325
  }
293
326
 
327
+ /**
328
+ * Which path the wearer took into the call.
329
+ *
330
+ * Diagnostic only — nothing behaves differently — but it is carried all the way to the native
331
+ * traces because the open question about video quality is exactly "do these two differ", and a
332
+ * log that cannot separate them cannot answer it.
333
+ */
334
+ export type AcsCallOrigin = "created" | "joined" | "unknown"
335
+
336
+ export function parseAcsCallOrigin(value: unknown): AcsCallOrigin {
337
+ return value === "created" || value === "joined" ? value : "unknown"
338
+ }
339
+
294
340
  type NativeModule = {
295
341
  prepareAgent?(options: {token: string; displayName?: string}): Promise<MeetingState>
296
342
  join(options: {
@@ -304,6 +350,7 @@ type NativeModule = {
304
350
  audioSource?: "glasses" | "phone"
305
351
  audioDelayMs?: number
306
352
  video?: AcsOutgoingVideo
353
+ origin?: AcsCallOrigin
307
354
  }): Promise<MeetingState & {ingestUrl?: string}>
308
355
  leave(): Promise<void>
309
356
  /**
@@ -315,6 +362,16 @@ type NativeModule = {
315
362
  * first one's teardown. Absent on natives that predate the signal.
316
363
  */
317
364
  leaveAndAwait?(options: {timeoutMs: number}): Promise<{completed: boolean}>
365
+ /**
366
+ * Wait for the SoftAP WHIP listener's port. `closed: false` means it is still held.
367
+ *
368
+ * Separate from [leaveAndAwait] because the listener outlives the ACS teardown on purpose: it
369
+ * answers `410` for a few seconds so an in-flight request from the glasses gets a status rather
370
+ * than a reset. Absent on natives that predate the barrier.
371
+ */
372
+ awaitIngestClosed?(options: {timeoutMs: number}): Promise<{closed: boolean}>
373
+ /** Drop the retiring WHIP listener now, skipping its grace period. */
374
+ forceCloseIngest?(): Promise<void>
318
375
  /**
319
376
  * End the group call for everyone, then tear this device down. Rejects when the capability is
320
377
  * denied or ACS refuses — and has still left the call. Absent on natives that predate End.
@@ -331,6 +388,8 @@ type NativeModule = {
331
388
  */
332
389
  joinScopedNetwork?(ssid: string, passphrase: string): Promise<string>
333
390
  joinScopedNetworkWithGateway?(ssid: string, passphrase: string, gateway: string): Promise<string>
391
+ /** Is this phone's Wi-Fi radio on? Absent on natives that predate the preflight. */
392
+ isWifiEnabled?(): Promise<boolean>
334
393
  beginTrace?(traceId: string): Promise<void>
335
394
  leaveScopedNetwork?(): Promise<void>
336
395
  cancelScopedNetworkJoin?(): Promise<void>
@@ -527,6 +586,8 @@ class AcsMeetingService {
527
586
  * wearer's voice at a native session that has already left the meeting.
528
587
  */
529
588
  private callGeneration = 0
589
+ /** Stamped on the host-side state traces so they can be grouped the same way the native ones are. */
590
+ private callOrigin: AcsCallOrigin = "unknown"
530
591
  private micTransport: MicTransport = "whip"
531
592
  private micSub: {remove: () => void} | null = null
532
593
  /** True between the pin/requirement being taken and released, so release is exactly once. */
@@ -576,6 +637,37 @@ class AcsMeetingService {
576
637
  return this.micTransport === "ble-lc3"
577
638
  }
578
639
 
640
+ /**
641
+ * Has the SoftAP video receiver released its port?
642
+ *
643
+ * A native that cannot answer reports `true`: it also predates the tombstone this waits out, so
644
+ * there is nothing for the barrier to be waiting on.
645
+ */
646
+ async awaitIngestClosed(timeoutMs: number): Promise<boolean> {
647
+ const native = getNative()
648
+ if (!native?.awaitIngestClosed) return true
649
+ const outcome = await native.awaitIngestClosed({timeoutMs})
650
+ return outcome.closed
651
+ }
652
+
653
+ /** Close the retiring WHIP listener now. No-op on natives without it. */
654
+ async forceCloseIngest(): Promise<void> {
655
+ await getNative()?.forceCloseIngest?.()
656
+ }
657
+
658
+ /**
659
+ * Is this phone's Wi-Fi radio on?
660
+ *
661
+ * `null` means the question cannot be answered on this host — iOS, or a native that predates the
662
+ * preflight — and callers must treat that as "carry on", not as "off". Refusing a call because
663
+ * we could not ask would break every platform that never had the problem.
664
+ */
665
+ async isWifiEnabled(): Promise<boolean | null> {
666
+ const native = getNative()
667
+ if (!native?.isWifiEnabled) return null
668
+ return await native.isWifiEnabled()
669
+ }
670
+
579
671
  /**
580
672
  * Join the glasses hotspot as a scoped, internet-less network, returning this phone's address on
581
673
  * it. Called before the ACS join, because the local WHIP listener has to bind to that address.
@@ -786,6 +878,7 @@ class AcsMeetingService {
786
878
  videoSource: AcsVideoSource
787
879
  displayName?: string
788
880
  video?: AcsOutgoingVideo
881
+ origin?: AcsCallOrigin
789
882
  },
790
883
  ): Promise<MeetingState> {
791
884
  const native = getNative()
@@ -826,6 +919,8 @@ class AcsMeetingService {
826
919
  preferredMic: useSettingsStore.getState().getSetting(SETTINGS.preferred_mic.key),
827
920
  })
828
921
  const joinStartedAt = Date.now()
922
+ const origin = parseAcsCallOrigin(args.origin)
923
+ this.callOrigin = origin
829
924
  softapTrace("acs_native_join", {
830
925
  packageName,
831
926
  generation,
@@ -834,6 +929,18 @@ class AcsMeetingService {
834
929
  micTransport: this.micTransport,
835
930
  audioDelayMs: lc3Uplink ? SOFTAP_LC3_AUDIO_DELAY_MS : 0,
836
931
  video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
932
+ origin,
933
+ })
934
+ // Duplicated from the native `native_join_options` line on purpose: if the native trace is
935
+ // missing from a capture — a bug report with only the JS log, a crash before export — this is
936
+ // the only record of what the two paths actually asked for.
937
+ softapTrace("acs_join_options", {
938
+ origin,
939
+ transport: args.videoSource.type,
940
+ width: video?.width ?? 0,
941
+ height: video?.height ?? 0,
942
+ fps: video?.fps ?? 0,
943
+ maxBitrateBps: video?.maxBitrateBps ?? 0,
837
944
  })
838
945
  try {
839
946
  const state = await native.join({
@@ -843,6 +950,7 @@ class AcsMeetingService {
843
950
  videoSource: args.videoSource,
844
951
  displayName: args.displayName,
845
952
  audioSource: resolved.source,
953
+ origin,
846
954
  ...(lc3Uplink ? {audioDelayMs: SOFTAP_LC3_AUDIO_DELAY_MS} : {}),
847
955
  ...(video ? {video} : {}),
848
956
  })
@@ -1315,8 +1423,22 @@ class AcsMeetingService {
1315
1423
 
1316
1424
  private bindNative(native: NativeModule, packageName: string): void {
1317
1425
  this.unbindNative()
1426
+ // Captured at bind, checked on every event. `unbindNative` removes the listener, but an event
1427
+ // already dispatched onto the JS queue still runs — and this is the one path where that lands
1428
+ // as the *previous* call's `disconnected` ending the call the wearer just started. Every other
1429
+ // callback that can outlive a session (mic PCM, the in-flight join) is fenced the same way.
1430
+ const generation = this.callGeneration
1318
1431
  this.subscriptions = [
1319
1432
  native.addListener("onState", (event) => {
1433
+ if (generation !== this.callGeneration) {
1434
+ softapTraceFailure("softap_stale_callback", {
1435
+ source: "acs_state",
1436
+ generation,
1437
+ current: this.callGeneration,
1438
+ state: String(event.state ?? "unknown"),
1439
+ })
1440
+ return
1441
+ }
1320
1442
  const audioSafety = parseAudioSafety(event.audioSafety)
1321
1443
  if (audioSafety === "unsafe") {
1322
1444
  console.error("[AcsMeeting] phase=audio-unsafe", {
@@ -1332,6 +1454,7 @@ class AcsMeetingService {
1332
1454
  ? {code: event.endReason_code as number, subcode: event.endReason_subcode as number}
1333
1455
  : undefined
1334
1456
  const capabilities = parseMeetingCapabilities(event.capabilities)
1457
+ const endReason = parseMeetingEndReason(event as Record<string, unknown>)
1335
1458
  const state: MeetingState = {
1336
1459
  state: (event.state as MeetingPhase) ?? "idle",
1337
1460
  muted: Boolean(event.muted),
@@ -1347,10 +1470,24 @@ class AcsMeetingService {
1347
1470
  ...(mediaSourceReason ? {mediaSourceReason} : {}),
1348
1471
  ...(callEndReason ? {callEndReason} : {}),
1349
1472
  ...(participants ? {participants} : {}),
1473
+ ...(endReason ? {endReason} : {}),
1350
1474
  // Absent means unknown, so keep the last known verdict rather than clearing it.
1351
1475
  ...((capabilities ?? this.lastState.capabilities) ? {capabilities: capabilities ?? this.lastState.capabilities} : {}),
1352
1476
  }
1477
+ const previous = this.lastState.state
1353
1478
  this.lastState = state
1479
+ if (state.state !== previous) {
1480
+ // The host's own copy of the transition. Native already traces `acs_call_state` with
1481
+ // timings; this one survives a capture where the native trace is absent, and is the
1482
+ // line the miniapp's timeline is reconciled against.
1483
+ softapTrace("acs_state", {
1484
+ state: state.state,
1485
+ previous,
1486
+ origin: this.callOrigin,
1487
+ mediaSource: state.mediaSource ?? "unknown",
1488
+ micTransport: state.micTransport ?? "unknown",
1489
+ })
1490
+ }
1354
1491
  console.log("[AcsMeeting] phase=native-state", {
1355
1492
  state: state.state,
1356
1493
  muted: state.muted,
@@ -1363,6 +1500,7 @@ class AcsMeetingService {
1363
1500
  callEndReason: state.callEndReason,
1364
1501
  micTransport: state.micTransport,
1365
1502
  participants: participants?.length,
1503
+ endReason: endReason ? `${endReason.code ?? "?"}/${endReason.subcode ?? "?"}` : undefined,
1366
1504
  })
1367
1505
  this.settleFirstFrameWaiters(mediaSource)
1368
1506
  this.onState?.(packageName, state)
@@ -23,7 +23,7 @@ import {unzip} from "react-native-zip-archive"
23
23
  import semver from "semver"
24
24
  import {AsyncResult, Result, result as Res} from "typesafe-ts"
25
25
 
26
- import type {AppletPermission, AppPermissionType, AppletType, ClientApp} from "../types/applet"
26
+ import type {AppletType, ClientApp} from "../types/applet"
27
27
  import {HardwareRequirement, HardwareRequirementLevel, HardwareType} from "../types"
28
28
  import {configuredDevHost} from "../utils/configuredDevHost"
29
29
  import {storage} from "../utils/storage/storage"
@@ -31,10 +31,12 @@ import {printDirectory} from "../utils/storage/zip"
31
31
  import {isInstalledMiniappAllowed, isOfflineSystemMiniappAllowed} from "../runtime/bootstrap"
32
32
  import {checkManifestVersions} from "./manifestVersionGate"
33
33
  import {normalizeManifestActions} from "./manifestActions"
34
+ import {normalizeManifestPermissions} from "./manifestPermissions"
34
35
  import {miniappInstallIdentityError, type MiniappInstallExpectations} from "./miniappInstallIdentity"
35
36
  import {miniappRunningRegistry} from "./MiniappRunningRegistry"
36
37
 
37
38
  export {normalizeManifestActions} from "./manifestActions"
39
+ export {normalizeManifestPermissions} from "./manifestPermissions"
38
40
 
39
41
  let installQueue: Promise<void> = Promise.resolve()
40
42
 
@@ -47,45 +49,6 @@ function serializeInstall<T>(operation: () => Promise<T>): Promise<T> {
47
49
  return result
48
50
  }
49
51
 
50
- const ALLOWED_PERMISSION_TYPES: ReadonlySet<AppPermissionType> = new Set<AppPermissionType>([
51
- "MICROPHONE",
52
- "CAMERA",
53
- "CALENDAR",
54
- "LOCATION",
55
- "BACKGROUND_LOCATION",
56
- "READ_NOTIFICATIONS",
57
- "POST_NOTIFICATIONS",
58
- ])
59
-
60
- /**
61
- * Normalize the `permissions` field from a miniapp.json manifest.
62
- *
63
- * New miniapps ship `[{type, required?, description?}]` objects. A few older
64
- * installed bundles may have `["MICROPHONE", ...]` plain strings. Accept both.
65
- */
66
- export function normalizeManifestPermissions(
67
- raw: Array<string | {type: string; required?: boolean; description?: string}> | undefined,
68
- ): AppletPermission[] {
69
- if (!Array.isArray(raw)) return []
70
- const out: AppletPermission[] = []
71
- for (const p of raw) {
72
- if (typeof p === "string") {
73
- if (ALLOWED_PERMISSION_TYPES.has(p as AppPermissionType)) {
74
- out.push({type: p as AppPermissionType, required: true})
75
- }
76
- } else if (p && typeof p === "object" && typeof p.type === "string") {
77
- if (ALLOWED_PERMISSION_TYPES.has(p.type as AppPermissionType)) {
78
- out.push({
79
- type: p.type as AppPermissionType,
80
- ...(typeof p.required === "boolean" ? {required: p.required} : {}),
81
- ...(typeof p.description === "string" ? {description: p.description} : {}),
82
- })
83
- }
84
- }
85
- }
86
- return out
87
- }
88
-
89
52
  function normalizeManifestType(raw: unknown): AppletType {
90
53
  return raw === "background" || raw === "system_dashboard" || raw === "standard" ? raw : "standard"
91
54
  }
@@ -645,9 +645,9 @@ export const cloudClientService = {
645
645
  },
646
646
 
647
647
  /** Device-side managed photo (cloud-v2): presign now, deliver bytes, await ready. */
648
- startManagedPhoto(opts: Record<string, unknown> = {}) {
648
+ startManagedPhoto() {
649
649
  if (!client) throw new Error("cloud client not connected")
650
- return client.runtime.startManagedPhoto(opts)
650
+ return client.runtime.startManagedPhoto()
651
651
  },
652
652
  awaitManagedPhotoReady(requestId: string) {
653
653
  if (!client) throw new Error("cloud client not connected")