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

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
@@ -37,12 +37,7 @@ export type SoftapPhase = "idle" | "starting" | "live" | "stopping" | "failed"
37
37
 
38
38
  /** A failure, named by the step that produced it so the UI and the logs agree on the cause. */
39
39
  export class SoftapCallError extends Error {
40
- constructor(
41
- readonly step: SoftapStep,
42
- readonly code: string,
43
- message: string,
44
- readonly cause?: unknown,
45
- ) {
40
+ constructor(readonly step: SoftapStep, readonly code: string, message: string, readonly cause?: unknown) {
46
41
  super(message)
47
42
  this.name = "SoftapCallError"
48
43
  }
@@ -51,6 +46,73 @@ export class SoftapCallError extends Error {
51
46
  /** Gallery sync already learned this: glasses report enabled before the SSID is in the phone scan. */
52
47
  export const HOTSPOT_BROADCAST_WAIT_MS = 3_000
53
48
 
49
+ /**
50
+ * What the glasses→phone hop is worth at 540p over a hotspot with one client and no contention.
51
+ *
52
+ * Not the ACS ceiling: this link is a metre of air between two devices that are already paired,
53
+ * and the profile's `maxBitrateBps` describes the phone→Teams hop, which crosses the internet.
54
+ * Sharing one number between them makes the easy hop obey the hard hop's limits.
55
+ */
56
+ export const GLASSES_PHONE_BITRATE_BPS = 2_500_000
57
+
58
+ /** Where the encoder starts. High, because the link is good from the first frame. */
59
+ const SOFTAP_START_BITRATE_BPS = 2_000_000
60
+
61
+ /**
62
+ * The floor. This is the whole point of the policy.
63
+ *
64
+ * WHIP's default has none, so after an ICE interruption WebRTC restarts at its own minimum and
65
+ * climbs by probing — which is the "potato for a while" the wearer reports. The link did not get
66
+ * worse; the estimator merely forgot what it knew. A floor says: this hop is a hotspot, not a
67
+ * congested uplink, and it is never worth less than this.
68
+ *
69
+ * That trade is the opposite of Mentra-Call#27's cloud-WHIP review: a 1 Mbps floor on Auto over
70
+ * LTE turns a weak WAN into freezes. This function is SoftAP-only. Cloud WHIP must not call it.
71
+ */
72
+ const SOFTAP_MIN_BITRATE_BPS = 1_200_000
73
+
74
+ /** Field names are the glasses' `WhipStreamConfig` parser's, where `bitrate` means the maximum. */
75
+ export interface SoftapVideoPolicy {
76
+ width: number
77
+ height: number
78
+ fps: number
79
+ bitrate: number
80
+ initialBitrateBps: number
81
+ minBitrateBps: number
82
+ }
83
+
84
+ /**
85
+ * The bitrate the glasses are told to hold on the hop to the phone.
86
+ *
87
+ * The hotspot has its own bitrate budget, independent of the phone-to-Teams cap. Above 540p,
88
+ * allow a higher requested ceiling while retaining the hotspot budget for low WAN caps.
89
+ */
90
+ export function softapVideoPolicy(video: {
91
+ width: number
92
+ height: number
93
+ fps: number
94
+ maxBitrateBps: number
95
+ }): SoftapVideoPolicy {
96
+ const isSmall = video.width * video.height <= 960 * 540
97
+ if (!Number.isFinite(video.maxBitrateBps) || video.maxBitrateBps <= 0) {
98
+ throw new Error(`invalid SoftAP video bitrate: ${video.maxBitrateBps}`)
99
+ }
100
+ const max = isSmall ? GLASSES_PHONE_BITRATE_BPS : Math.max(GLASSES_PHONE_BITRATE_BPS, video.maxBitrateBps)
101
+ const initialBitrateBps = Math.min(SOFTAP_START_BITRATE_BPS, max)
102
+ const minBitrateBps = Math.min(SOFTAP_MIN_BITRATE_BPS, initialBitrateBps)
103
+ if (!(minBitrateBps <= initialBitrateBps && initialBitrateBps <= max)) {
104
+ throw new Error(`incoherent SoftAP video policy: min=${minBitrateBps} start=${initialBitrateBps} max=${max}`)
105
+ }
106
+ return {
107
+ width: video.width,
108
+ height: video.height,
109
+ fps: video.fps,
110
+ bitrate: max,
111
+ initialBitrateBps,
112
+ minBitrateBps,
113
+ }
114
+ }
115
+
54
116
  /**
55
117
  * Android's WifiNetworkSpecifier called onUnavailable. The native message lists three causes
56
118
  * because the callback does not say which one happened; on the 18:02 Samsung path the SSID was
@@ -94,6 +156,15 @@ export interface SoftapProgress {
94
156
  export type SoftapStepReporter = (detail: string) => void
95
157
 
96
158
  export interface SoftapCallDeps {
159
+ /**
160
+ * Is this phone's Wi-Fi radio on? Asked before anything is built.
161
+ *
162
+ * The station radio is what reaches the glasses, and no app can turn it on since Android 10.
163
+ * Finding out at `scopedJoin` means the glasses already raised a hotspot for a join that cannot
164
+ * happen, so the wearer pays a teardown for a condition that was knowable up front. Optional:
165
+ * a host that cannot answer skips the preflight and relies on the native throw at `scopedJoin`.
166
+ */
167
+ isWifiEnabled?(): Promise<boolean>
97
168
  /** Enable the glasses hotspot and return its credentials. */
98
169
  startHotspot(report?: SoftapStepReporter): Promise<{ssid: string; passphrase: string}>
99
170
  /**
@@ -334,6 +405,43 @@ export class SoftapCallTransport {
334
405
  return [...this.teardownFailures]
335
406
  }
336
407
 
408
+ /**
409
+ * Refuse the call while the Wi-Fi radio is off, before the glasses are asked for anything.
410
+ *
411
+ * Ordered ahead of the hotspot step rather than folded into `scopedJoin` because the cost of
412
+ * learning late is paid by the glasses: a hotspot raised, a teardown, and a wearer watching a
413
+ * checklist fail at step 2 for a toggle on their own phone. The native join keeps its own throw
414
+ * for the radio that goes off in between — this is the explainable path, that one is the racy
415
+ * backstop.
416
+ *
417
+ * A probe that throws is treated as "unknown, carry on": an unanswerable question about the
418
+ * radio must not be the thing that stops a call the radio would have carried.
419
+ */
420
+ private async preflightWifi(): Promise<void> {
421
+ if (!this.deps.isWifiEnabled) return
422
+ let enabled: boolean
423
+ try {
424
+ enabled = await this.deps.isWifiEnabled()
425
+ } catch (error) {
426
+ softapTraceFailure("softap_wifi_preflight_unknown", {
427
+ reason: error instanceof Error ? error.message : String(error),
428
+ })
429
+ return
430
+ }
431
+ softapTrace("softap_wifi_preflight", {enabled})
432
+ if (enabled) return
433
+ this.setStep("hotspot", {status: "failed", error: "This phone's Wi-Fi is off"})
434
+ this.emitProgress()
435
+ // Code in the message as well as on the error: the miniapp classifies on the message today
436
+ // (the structured code only arrives on the join rejection), and every other SoftAP failure
437
+ // carries its code the same way.
438
+ throw new SoftapCallError(
439
+ "hotspot",
440
+ "SOFTAP_WIFI_DISABLED",
441
+ "SOFTAP_WIFI_DISABLED: this phone's Wi-Fi is off, so it cannot reach the glasses.",
442
+ )
443
+ }
444
+
337
445
  /**
338
446
  * Runs the sequence. On any failure the partial sequence is torn down before the error is
339
447
  * rethrown, so a failed start never leaves a hotspot up or a publisher running.
@@ -370,6 +478,8 @@ export class SoftapCallTransport {
370
478
  this.emitProgress()
371
479
 
372
480
  try {
481
+ await this.preflightWifi()
482
+
373
483
  await this.step(generation, "hotspot", "HOTSPOT_FAILED", async (report) => {
374
484
  report("Asking the glasses to turn on their hotspot")
375
485
  const hotspot = await this.deps.startHotspot(report)
@@ -389,9 +499,7 @@ export class SoftapCallTransport {
389
499
  const hotspot = this.requireHotspot()
390
500
  let bindAddress: string | undefined
391
501
  await this.step(generation, "scopedJoin", "SCOPED_JOIN_FAILED", async (report) => {
392
- report(
393
- `Phone joining ${hotspot.ssid}. Turn Wi-Fi on if a panel opens — Teams stays on cellular.`,
394
- )
502
+ report(`Phone joining ${hotspot.ssid}. Turn Wi-Fi on if a panel opens — Teams stays on cellular.`)
395
503
  bindAddress = await this.deps.joinScopedNetwork(hotspot.ssid, hotspot.passphrase, report)
396
504
  softapTrace("scoped_network_joined", {bindAddress: bindAddress ?? "unknown"})
397
505
  if (bindAddress) report(`Phone is ${bindAddress} on ${hotspot.ssid}`)
@@ -718,10 +826,17 @@ export function createSoftapCallDeps(args: {
718
826
  meetingUrl: string
719
827
  token: string
720
828
  displayName?: string
829
+ /**
830
+ * The ACS outgoing profile. Used here only to size the glasses→phone hop; when absent the
831
+ * glasses keep their WHIP defaults, which is what every pre-policy build did.
832
+ */
833
+ video?: {width: number; height: number; fps: number; maxBitrateBps: number}
721
834
  /** Resolves when the meeting reports a frame reached ACS; rejects on a failed feed. */
722
835
  awaitFirstFrame: () => Promise<void>
723
836
  subsystems: {
724
837
  setHotspotState: (enabled: boolean) => Promise<{state: string; ssid?: string; password?: string; localIp?: string}>
838
+ /** Whether this phone's Wi-Fi radio is on. Optional: only Android hosts can answer it. */
839
+ isWifiEnabled?: () => Promise<boolean>
725
840
  joinScopedNetwork: (ssid: string, passphrase: string, gateway?: string) => Promise<string | undefined>
726
841
  leaveScopedNetwork: () => Promise<void>
727
842
  cancelScopedNetworkJoin?: () => Promise<void>
@@ -740,7 +855,13 @@ export function createSoftapCallDeps(args: {
740
855
  ingestUrl: () => string | null
741
856
  startPublishing: (
742
857
  packageName: string,
743
- options: {streamUrl: string; ice: {stun: string}; traceId: string; captureAudio?: boolean},
858
+ options: {
859
+ streamUrl: string
860
+ ice: {stun: string}
861
+ traceId: string
862
+ captureAudio?: boolean
863
+ video?: SoftapVideoPolicy
864
+ },
744
865
  ) => Promise<unknown>
745
866
  stopPublishing: (packageName: string) => Promise<void>
746
867
  /**
@@ -784,6 +905,7 @@ export function createSoftapCallDeps(args: {
784
905
  const hotspotBroadcastWaitMs = args.hotspotBroadcastWaitMs ?? HOTSPOT_BROADCAST_WAIT_MS
785
906
  let gatewayAddress: string | undefined
786
907
  return {
908
+ isWifiEnabled: subsystems.isWifiEnabled ? () => subsystems.isWifiEnabled!() : undefined,
787
909
  startHotspot: async (report) => {
788
910
  const enable = async () => {
789
911
  const status = await subsystems.setHotspotState(true)
@@ -809,7 +931,7 @@ export function createSoftapCallDeps(args: {
809
931
  report?.("Glasses hotspot did not start; turning it off and trying again")
810
932
  await subsystems.setHotspotState(false)
811
933
  if (hotspotBroadcastWaitMs > 0) {
812
- await new Promise<void>(resolve => setTimeout(resolve, Math.min(1_000, hotspotBroadcastWaitMs)))
934
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(1_000, hotspotBroadcastWaitMs)))
813
935
  }
814
936
  return await enable()
815
937
  }
@@ -818,7 +940,7 @@ export function createSoftapCallDeps(args: {
818
940
  if (hotspotBroadcastWaitMs <= 0) return
819
941
  softapTrace("hotspot_broadcast_wait", {ms: hotspotBroadcastWaitMs})
820
942
  report?.(`Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`)
821
- await new Promise<void>(resolve => setTimeout(resolve, hotspotBroadcastWaitMs))
943
+ await new Promise<void>((resolve) => setTimeout(resolve, hotspotBroadcastWaitMs))
822
944
  },
823
945
  stopHotspot: async () => {
824
946
  await subsystems.setHotspotState(false)
@@ -842,10 +964,8 @@ export function createSoftapCallDeps(args: {
842
964
  if (status.state !== "enabled" || !status.ssid || !status.password) throw error
843
965
  gatewayAddress = status.localIp
844
966
  if (hotspotBroadcastWaitMs > 0) {
845
- report?.(
846
- `Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`,
847
- )
848
- await new Promise<void>(resolve => setTimeout(resolve, hotspotBroadcastWaitMs))
967
+ report?.(`Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`)
968
+ await new Promise<void>((resolve) => setTimeout(resolve, hotspotBroadcastWaitMs))
849
969
  }
850
970
  address = await joinOnce(status.ssid, status.password)
851
971
  }
@@ -930,6 +1050,20 @@ export function createSoftapCallDeps(args: {
930
1050
  ? "Publishing video only; the wearer's voice comes over Bluetooth LC3"
931
1051
  : "Publishing video and the glasses microphone",
932
1052
  )
1053
+ const policy = args.video ? softapVideoPolicy(args.video) : undefined
1054
+ if (policy) {
1055
+ // Traced at send time rather than read back: the glasses log what they applied in
1056
+ // `applyBitrateConstraints`, and the pair of lines is what shows a clamp we did not
1057
+ // intend. One line alone can only ever show agreement with itself.
1058
+ softapTrace("softap_video_policy", {
1059
+ width: policy.width,
1060
+ height: policy.height,
1061
+ fps: policy.fps,
1062
+ min: policy.minBitrateBps,
1063
+ start: policy.initialBitrateBps,
1064
+ max: policy.bitrate,
1065
+ })
1066
+ }
933
1067
  try {
934
1068
  await subsystems.startPublishing(packageName, {
935
1069
  streamUrl: ingestUrl,
@@ -938,6 +1072,7 @@ export function createSoftapCallDeps(args: {
938
1072
  ice: {stun: ""},
939
1073
  traceId,
940
1074
  captureAudio: !lc3Uplink,
1075
+ ...(policy ? {video: policy} : {}),
941
1076
  })
942
1077
  } finally {
943
1078
  unsubscribe?.()
@@ -0,0 +1,44 @@
1
+ import type {AppletPermission, AppPermissionType} from "../types/applet"
2
+
3
+ const ALLOWED_PERMISSION_TYPES: ReadonlySet<AppPermissionType> = new Set<AppPermissionType>([
4
+ "MICROPHONE",
5
+ "CAMERA",
6
+ // Phone camera, not glasses camera. Mentra Call needs this on the home-screen
7
+ // permission list so opening the miniapp prompts for Android CAMERA; without
8
+ // it the join later fails because ACS publishes video from this phone.
9
+ "PHONE_CAMERA",
10
+ "CALENDAR",
11
+ "LOCATION",
12
+ "BACKGROUND_LOCATION",
13
+ "READ_NOTIFICATIONS",
14
+ "POST_NOTIFICATIONS",
15
+ ])
16
+
17
+ /**
18
+ * Normalize the `permissions` field from a miniapp.json manifest.
19
+ *
20
+ * New miniapps ship `[{type, required?, description?}]` objects. A few older
21
+ * installed bundles may have `["MICROPHONE", ...]` plain strings. Accept both.
22
+ */
23
+ export function normalizeManifestPermissions(
24
+ raw: Array<string | {type: string; required?: boolean; description?: string}> | undefined,
25
+ ): AppletPermission[] {
26
+ if (!Array.isArray(raw)) return []
27
+ const out: AppletPermission[] = []
28
+ for (const p of raw) {
29
+ if (typeof p === "string") {
30
+ if (ALLOWED_PERMISSION_TYPES.has(p as AppPermissionType)) {
31
+ out.push({type: p as AppPermissionType, required: true})
32
+ }
33
+ } else if (p && typeof p === "object" && typeof p.type === "string") {
34
+ if (ALLOWED_PERMISSION_TYPES.has(p.type as AppPermissionType)) {
35
+ out.push({
36
+ type: p.type as AppPermissionType,
37
+ ...(typeof p.required === "boolean" ? {required: p.required} : {}),
38
+ ...(typeof p.description === "string" ? {description: p.description} : {}),
39
+ })
40
+ }
41
+ }
42
+ }
43
+ return out
44
+ }
@@ -19,7 +19,18 @@ export type AppletType = "standard" | "background" | "system_dashboard"
19
19
  export type AppPermissionType =
20
20
  | "ALL"
21
21
  | "MICROPHONE"
22
+ /** The glasses camera. */
22
23
  | "CAMERA"
24
+ /**
25
+ * This phone's own camera.
26
+ *
27
+ * Separate from `CAMERA` because that one has always meant the glasses, for every miniapp, and
28
+ * is never prompted for as an OS permission. A miniapp that publishes video *from the phone*
29
+ * (an ACS Teams call does, even though the frames originate on the glasses) needs the Android
30
+ * `CAMERA` runtime permission, and asking for it under the existing type would start prompting
31
+ * every miniapp on the platform for a camera it does not use.
32
+ */
33
+ | "PHONE_CAMERA"
23
34
  | "CALENDAR"
24
35
  | "LOCATION"
25
36
  | "BACKGROUND_LOCATION"