@mentra/engine 3.2.0-dev.120 → 3.2.0-dev.122

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.
@@ -34,6 +34,7 @@ import {BgTimer} from "../utils/timers"
34
34
  import devServerBridge from "./DevServerBridge"
35
35
  import {islandNotifications} from "./NotificationsEmitter"
36
36
  import {isGlassesConnected} from "./GlassesReadiness"
37
+ import {toMiniappConnectionData} from "./GlassesStatusProjection"
37
38
  import audioPlaybackService from "./AudioPlaybackService"
38
39
  import {phoneLocationService} from "./PhoneLocationService"
39
40
  import {useGlassesStore} from "../stores/glasses"
@@ -759,6 +760,22 @@ class LocalMiniappRuntime {
759
760
  this.replaceStreamSubscribers(packageName, existing.subscriptions, [])
760
761
  this.recomputeMicRequirements()
761
762
  this.updateCloudSubscriptions()
763
+ // Managed streams and ACS meetings deliberately survive a respawn: the new
764
+ // incarnation re-adopts them on restore (Mentra Call does exactly this) so a
765
+ // WebView reload does not drop the wearer out of a live call. An unmanaged
766
+ // stream cannot be re-adopted — startUnmanaged rejects while any stream is
767
+ // active, even for the same owner — so release it or the respawned miniapp
768
+ // is stuck behind STREAM_ALREADY_ACTIVE until the process restarts.
769
+ const streamSnapshot = phoneStreamCoordinator.getDiagnosticSnapshot()
770
+ if (
771
+ streamSnapshot.active === true &&
772
+ streamSnapshot.kind === "unmanaged" &&
773
+ streamSnapshot.ownerPackageName === packageName
774
+ ) {
775
+ void phoneStreamCoordinator.stop(packageName).catch((error) => {
776
+ console.warn(`${LOG_TAG}: failed to stop unmanaged stream while replacing ${packageName}`, error)
777
+ })
778
+ }
762
779
  }
763
780
  this.connectedApps.set(packageName, {
764
781
  subscriptions: new Set(),
@@ -1817,7 +1834,10 @@ class LocalMiniappRuntime {
1817
1834
  this.sendToMiniapp(packageName, {
1818
1835
  type: MiniappResponseType.EVENT,
1819
1836
  streamType: "glasses_connection",
1820
- data: glassesState,
1837
+ data: {
1838
+ connected: glassesState.connected,
1839
+ modelName: glassesState.deviceModel || undefined,
1840
+ },
1821
1841
  })
1822
1842
  } else if (stream === "glasses_wifi") {
1823
1843
  // Snapshot the current glasses Wi-Fi state on subscribe (like battery).
@@ -1836,6 +1856,7 @@ class LocalMiniappRuntime {
1836
1856
  streamType: "glasses_wifi",
1837
1857
  data: {
1838
1858
  connected,
1859
+ linkConnected: glassesState.connected === true,
1839
1860
  ssid: connected ? wifi?.ssid : undefined,
1840
1861
  localIp: connected ? wifi?.localIp : undefined,
1841
1862
  timestamp: Date.now(),
@@ -3261,6 +3282,30 @@ class LocalMiniappRuntime {
3261
3282
  // Photo + streaming handlers (cloud-coordinated)
3262
3283
  // ===========================================================================
3263
3284
 
3285
+ /**
3286
+ * Manifest permission gate shared by the camera-and-mic RPCs (streams, meetings).
3287
+ * Sends PERMISSION_NOT_DECLARED and returns false when the miniapp did not declare
3288
+ * `permission`; the caller must return without doing the work.
3289
+ */
3290
+ private requireManifestPermission(
3291
+ packageName: string,
3292
+ requestId: string | undefined,
3293
+ permission: "CAMERA" | "MICROPHONE",
3294
+ purpose: string,
3295
+ operation: MiniappRequestType,
3296
+ ): boolean {
3297
+ const app = this.connectedApps.get(packageName)
3298
+ if (app?.installedManifest?.permissions?.some((p) => p.type === permission)) return true
3299
+ logPermissionNotDeclared(packageName, permission, purpose, `{"type": "${permission}"}`)
3300
+ this.sendResult(packageName, requestId, false, undefined, {
3301
+ code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
3302
+ message: `${permission} permission not declared in miniapp.json. Add {"type": "${permission}"} to the "permissions" array.`,
3303
+ permission,
3304
+ operation,
3305
+ })
3306
+ return false
3307
+ }
3308
+
3264
3309
  private async handlePhoto(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
3265
3310
  // Manifest CAMERA permission gate.
3266
3311
  const app = this.connectedApps.get(packageName)
@@ -3419,6 +3464,10 @@ class LocalMiniappRuntime {
3419
3464
  })
3420
3465
  return
3421
3466
  }
3467
+ // A stream is the glasses camera (and mic) leaving the device; gate it like a photo.
3468
+ if (!this.requireManifestPermission(packageName, requestId, "CAMERA", "to stream the camera", MiniappRequestType.STREAM_START)) {
3469
+ return
3470
+ }
3422
3471
  try {
3423
3472
  const result = await streaming.startUnmanaged(packageName, {
3424
3473
  streamUrl: payload.streamUrl as string,
@@ -3476,6 +3525,17 @@ class LocalMiniappRuntime {
3476
3525
  })
3477
3526
  return
3478
3527
  }
3528
+ if (
3529
+ !this.requireManifestPermission(
3530
+ packageName,
3531
+ requestId,
3532
+ "CAMERA",
3533
+ "to stream the camera",
3534
+ MiniappRequestType.MANAGED_STREAM_START,
3535
+ )
3536
+ ) {
3537
+ return
3538
+ }
3479
3539
  try {
3480
3540
  const result = await streaming.startManaged(packageName, {
3481
3541
  restreamDestinations: payload.restreamDestinations as Array<string | {url: string; name?: string}> | undefined,
@@ -3519,6 +3579,14 @@ class LocalMiniappRuntime {
3519
3579
  requestId?: string,
3520
3580
  ): Promise<void> {
3521
3581
  this.ensureMeetingStateBridge()
3582
+ // A meeting puts the glasses camera and mic in front of remote strangers; it
3583
+ // needs both declared, same as photo/stream and mic capture do individually.
3584
+ if (
3585
+ !this.requireManifestPermission(packageName, requestId, "CAMERA", "to join a meeting", MiniappRequestType.MEETING_JOIN) ||
3586
+ !this.requireManifestPermission(packageName, requestId, "MICROPHONE", "to join a meeting", MiniappRequestType.MEETING_JOIN)
3587
+ ) {
3588
+ return
3589
+ }
3522
3590
  const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : ""
3523
3591
  const token = typeof payload.token === "string" ? payload.token : ""
3524
3592
  const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
@@ -4045,6 +4113,9 @@ class LocalMiniappRuntime {
4045
4113
  // The bare `touch_event` stream above still catches `onTouch(handler)`.
4046
4114
  let perGestureStream: string | null = null
4047
4115
  let outboundData = data
4116
+ if (normalizedStream === MiniappStreamType.GLASSES_CONNECTION) {
4117
+ outboundData = toMiniappConnectionData(data) ?? data
4118
+ }
4048
4119
  if (normalizedStream === MiniappStreamType.TOUCH_EVENT) {
4049
4120
  // The Bluetooth SDK delivers the gesture under `gestureName` (single_tap /
4050
4121
  // double_tap / triple_tap / long_press / swipe_up / swipe_down). Surface it
@@ -25,7 +25,7 @@
25
25
 
26
26
  import {File} from "expo-file-system"
27
27
 
28
- import {decideDevLaunchRoute} from "../utils/devMiniappLaunch"
28
+ import {resolveDevBundleSource} from "../utils/devMiniappSnapshot"
29
29
  import {storage} from "../utils/storage/storage"
30
30
  import appRegistry, {getLocalAppRunningState, saveLocalAppRunningState} from "./AppRegistry"
31
31
  import devServerBridge from "./DevServerBridge"
@@ -96,67 +96,89 @@ class MiniappLauncher {
96
96
  * package. Handles both dev (HTTP off the running dev server) and released
97
97
  * (file:// from the installed snapshot). Reads disk/network/storage; does
98
98
  * NOT spawn. Returns null when the bundle can't be resolved (dev server
99
- * unreachable, missing entry, no installed version).
99
+ * unreachable with no on-disk snapshot, missing entry, no installed version).
100
100
  */
101
101
  async resolveBundle(packageName: string, hints?: LaunchHints): Promise<ResolvedBundle | null> {
102
102
  const devUrl = hints?.devUrl ?? this.storedDevUrl(packageName)
103
103
 
104
- // --- Dev: load directly off the local dev server over HTTP. ---
104
+ // --- Dev: live HTTP, then the last on-disk snapshot if the laptop is gone. ---
105
105
  if (devUrl) {
106
- const route = await decideDevLaunchRoute(packageName, devUrl)
107
- if (route.decision === "offline" || !route.manifest) return null
108
- const manifest = route.manifest
109
- const entry = manifest.entry as {background?: string; ui?: string} | undefined
110
- if (!entry?.background) return null
111
-
112
- // Use the host that actually answered — may differ from the stored IP
113
- // after a laptop Wi-Fi change (mDNS / Metro failover inside decideDevLaunchRoute).
114
- const base = route.resolvedUrl.replace(/\/$/, "")
115
- // entry.* are bundle-root paths (dist/ stripped); the dev server serves
116
- // files relative to cwd, so prepend dist/.
117
- const bgUrl = `${base}/dist/${entry.background.replace(/^\.?\/+/, "")}`
118
- const uiUri = entry.ui ? `${base}/dist/${entry.ui.replace(/^\.?\/+/, "")}` : null
119
-
120
- const perms = manifest.permissions as Array<{type?: string} | string> | undefined
121
- const declaredPermissions = (perms ?? [])
122
- .map((p) => (typeof p === "string" ? p : p?.type))
123
- .filter((t): t is string => typeof t === "string")
124
- const installedManifest: InstalledMiniappManifest = {
125
- packageName: typeof manifest.packageName === "string" ? manifest.packageName : packageName,
126
- name: manifest.name,
127
- version: typeof manifest.version === "string" ? manifest.version : undefined,
128
- sdkVersion: typeof manifest.sdkVersion === "string" ? manifest.sdkVersion : undefined,
129
- minHostVersion: typeof manifest.minHostVersion === "string" ? manifest.minHostVersion : undefined,
130
- type: typeof manifest.type === "string" ? manifest.type : undefined,
131
- entry: manifest.entry as InstalledMiniappManifest["entry"],
132
- permissions: manifest.permissions as InstalledMiniappManifest["permissions"],
133
- hardwareRequirements: manifest.hardwareRequirements as InstalledMiniappManifest["hardwareRequirements"],
134
- actions: manifest.actions as InstalledMiniappManifest["actions"],
106
+ const source = await resolveDevBundleSource(packageName, devUrl)
107
+ if (source.kind === "live") {
108
+ const live = await this.resolveLiveHttp(packageName, source.resolvedUrl, source.manifest, hints)
109
+ if (live) return live
110
+ // Live probe succeeded but the entry fetch failed — still try disk.
135
111
  }
136
-
137
- let bgSource: string
138
- try {
139
- const res = await fetch(bgUrl)
140
- if (!res.ok) return null
141
- bgSource = await res.text()
142
- } catch {
143
- return null
144
- }
145
-
146
- return {
147
- bgSource,
148
- uiUri,
149
- uiBaseDir: uiUri ? uiUri.replace(/\/[^/]+$/, "/") : null,
150
- declaredPermissions,
151
- installedManifest,
152
- devUrl: route.resolvedUrl,
153
- devPort: this.resolveDevPort(hints?.devPort, packageName),
112
+ const snapshotVersion =
113
+ source.kind === "snapshot" ? source.version : appRegistry.getLatestDevSnapshotVersion(packageName)
114
+ if (snapshotVersion) {
115
+ const snapshot = await this.resolveInstalledBundle(packageName, snapshotVersion)
116
+ if (snapshot) return snapshot
154
117
  }
118
+ return null
155
119
  }
156
120
 
157
121
  // --- Released: resolve from the installed file:// snapshot. ---
158
122
  const version = hints?.version ?? (await appRegistry.getActiveVersion(packageName))
159
123
  if (!version) return null
124
+ return this.resolveInstalledBundle(packageName, version)
125
+ }
126
+
127
+ private async resolveLiveHttp(
128
+ packageName: string,
129
+ resolvedUrl: string,
130
+ manifest: {entry?: unknown; permissions?: unknown; [key: string]: unknown},
131
+ hints?: LaunchHints,
132
+ ): Promise<ResolvedBundle | null> {
133
+ const entry = manifest.entry as {background?: string; ui?: string} | undefined
134
+ if (!entry?.background) return null
135
+
136
+ // Use the host that actually answered — may differ from the stored IP
137
+ // after a laptop Wi-Fi change (mDNS / Metro failover inside decideDevLaunchRoute).
138
+ const base = resolvedUrl.replace(/\/$/, "")
139
+ // entry.* are bundle-root paths (dist/ stripped); the dev server serves
140
+ // files relative to cwd, so prepend dist/.
141
+ const bgUrl = `${base}/dist/${entry.background.replace(/^\.?\/+/, "")}`
142
+ const uiUri = entry.ui ? `${base}/dist/${entry.ui.replace(/^\.?\/+/, "")}` : null
143
+
144
+ const perms = manifest.permissions as Array<{type?: string} | string> | undefined
145
+ const declaredPermissions = (perms ?? [])
146
+ .map((p) => (typeof p === "string" ? p : p?.type))
147
+ .filter((t): t is string => typeof t === "string")
148
+ const installedManifest: InstalledMiniappManifest = {
149
+ packageName: typeof manifest.packageName === "string" ? manifest.packageName : packageName,
150
+ name: typeof manifest.name === "string" ? manifest.name : undefined,
151
+ version: typeof manifest.version === "string" ? manifest.version : undefined,
152
+ sdkVersion: typeof manifest.sdkVersion === "string" ? manifest.sdkVersion : undefined,
153
+ minHostVersion: typeof manifest.minHostVersion === "string" ? manifest.minHostVersion : undefined,
154
+ type: typeof manifest.type === "string" ? manifest.type : undefined,
155
+ entry: manifest.entry as InstalledMiniappManifest["entry"],
156
+ permissions: manifest.permissions as InstalledMiniappManifest["permissions"],
157
+ hardwareRequirements: manifest.hardwareRequirements as InstalledMiniappManifest["hardwareRequirements"],
158
+ actions: manifest.actions as InstalledMiniappManifest["actions"],
159
+ }
160
+
161
+ let bgSource: string
162
+ try {
163
+ const res = await fetch(bgUrl)
164
+ if (!res.ok) return null
165
+ bgSource = await res.text()
166
+ } catch {
167
+ return null
168
+ }
169
+
170
+ return {
171
+ bgSource,
172
+ uiUri,
173
+ uiBaseDir: uiUri ? uiUri.replace(/\/[^/]+$/, "/") : null,
174
+ declaredPermissions,
175
+ installedManifest,
176
+ devUrl: resolvedUrl,
177
+ devPort: this.resolveDevPort(hints?.devPort, packageName),
178
+ }
179
+ }
180
+
181
+ private async resolveInstalledBundle(packageName: string, version: string): Promise<ResolvedBundle | null> {
160
182
  const entryPaths = appRegistry.getMiniappEntryPaths(packageName, version)
161
183
  if (!entryPaths?.background) return null
162
184
 
@@ -203,6 +225,7 @@ class MiniappLauncher {
203
225
  uiBaseDir: entryPaths.ui ? entryPaths.ui.replace(/\/[^/]+$/, "/") : null,
204
226
  declaredPermissions,
205
227
  installedManifest,
228
+ // Snapshot fallback is file:// — do not wire the sidecar; the laptop is gone.
206
229
  devUrl: null,
207
230
  devPort: null,
208
231
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Dev miniapp snapshot — keep a local copy of the last live-dev bundle so
3
+ * walking away from the laptop still opens the miniapp while Mentra App is
4
+ * running (and after, as long as the on-disk `dev-*` snapshot remains).
5
+ *
6
+ * Live launches load HTTP off `mentra-miniapp dev` for hot reload. After a
7
+ * successful reachability probe we fire-and-forget `${devUrl}/bundle.zip`
8
+ * into `lmas/<pkg>/dev-<ms>/`. When the laptop is unreachable, launch
9
+ * routes and MiniappLauncher fall back to that snapshot instead of the
10
+ * "Dev server offline" dead-end.
11
+ */
12
+
13
+ import appRegistry from "../services/AppRegistry"
14
+ import {storage} from "./storage/storage"
15
+ import {
16
+ decideDevLaunchRoute,
17
+ type DecideDevLaunchOptions,
18
+ type DevLaunchResult,
19
+ type DevManifest,
20
+ } from "./devMiniappLaunch"
21
+
22
+ export type DevOpenDecision =
23
+ | {decision: "live"; manifest: DevManifest; resolvedUrl: string}
24
+ | {decision: "cached"}
25
+ | {decision: "offline"}
26
+
27
+ export type DevBundleSource =
28
+ | {kind: "live"; resolvedUrl: string; manifest: DevManifest}
29
+ | {kind: "snapshot"; version: string}
30
+ | {kind: "none"}
31
+
32
+ const snapshotInFlight = new Map<string, Promise<void>>()
33
+
34
+ /** Candidate zip URLs: same-origin `/bundle.zip`, then the sidecar path. */
35
+ export function snapshotCandidateUrls(baseUrl: string, sidecarPort?: number | null): string[] {
36
+ const trimmed = baseUrl.replace(/\/$/, "")
37
+ const urls = [`${trimmed}/bundle.zip`]
38
+ try {
39
+ const url = new URL(trimmed)
40
+ const userPort = Number(url.port) || (url.protocol === "https:" ? 443 : 80)
41
+ const sidecar = sidecarPort && sidecarPort > 0 ? sidecarPort : userPort + 1
42
+ url.port = String(sidecar)
43
+ const sidecarZip = `${url.origin}/__mentra_dev/bundle.zip`
44
+ if (!urls.includes(sidecarZip)) urls.push(sidecarZip)
45
+ } catch {
46
+ /* ignore malformed base */
47
+ }
48
+ return urls
49
+ }
50
+
51
+ function storedSidecarPort(packageName: string): number | undefined {
52
+ const stored = storage.load<number>(`${packageName}_dev_port`)
53
+ return stored.is_ok() && Number.isFinite(stored.value) ? stored.value : undefined
54
+ }
55
+
56
+ /**
57
+ * Download the current live-dev zip into `lmas/<pkg>/dev-<ms>/` and keep
58
+ * only the newest snapshot. Coalesces concurrent calls per package.
59
+ */
60
+ export function queueDevSnapshot(packageName: string, baseUrl: string, sidecarPort?: number | null): void {
61
+ if (!packageName || !baseUrl) return
62
+ if (snapshotInFlight.has(packageName)) return
63
+ const promise = installDevSnapshot(packageName, baseUrl, sidecarPort ?? storedSidecarPort(packageName)).finally(() => {
64
+ snapshotInFlight.delete(packageName)
65
+ })
66
+ snapshotInFlight.set(packageName, promise)
67
+ }
68
+
69
+ async function installDevSnapshot(packageName: string, baseUrl: string, sidecarPort?: number | null): Promise<void> {
70
+ let lastError: unknown
71
+ for (const url of snapshotCandidateUrls(baseUrl, sidecarPort)) {
72
+ const res = await appRegistry.installFromUrl(url, {
73
+ versionOverride: `dev-${Date.now()}`,
74
+ releaseIdentity: {source: "dev_snapshot"},
75
+ })
76
+ if (res.is_ok()) {
77
+ appRegistry.gcDevVersions(packageName, 1)
78
+ return
79
+ }
80
+ lastError = res.error
81
+ }
82
+ console.warn(`Dev snapshot failed for ${packageName}:`, lastError)
83
+ }
84
+
85
+ /** Reachability first; if the laptop is down, open the last local snapshot. */
86
+ export async function decideDevOpenRoute(
87
+ packageName: string,
88
+ devUrl: string,
89
+ options?: DecideDevLaunchOptions,
90
+ ): Promise<DevOpenDecision> {
91
+ const route = await decideDevLaunchRoute(packageName, devUrl, options)
92
+ if (route.decision === "live") {
93
+ queueDevSnapshot(packageName, route.resolvedUrl)
94
+ return route
95
+ }
96
+ if (packageName && appRegistry.hasDevSnapshot(packageName)) {
97
+ return {decision: "cached"}
98
+ }
99
+ return {decision: "offline"}
100
+ }
101
+
102
+ /**
103
+ * Same live-vs-snapshot choice MiniappLauncher uses to resolve a bundle.
104
+ * Live also kicks a background snapshot so the next offline open has a copy.
105
+ */
106
+ export async function resolveDevBundleSource(
107
+ packageName: string,
108
+ devUrl: string,
109
+ options?: DecideDevLaunchOptions,
110
+ ): Promise<DevBundleSource> {
111
+ const route: DevLaunchResult = await decideDevLaunchRoute(packageName, devUrl, options)
112
+ if (route.decision === "live" && route.manifest) {
113
+ queueDevSnapshot(packageName, route.resolvedUrl)
114
+ return {kind: "live", resolvedUrl: route.resolvedUrl, manifest: route.manifest}
115
+ }
116
+ const version = appRegistry.getLatestDevSnapshotVersion(packageName)
117
+ if (version) return {kind: "snapshot", version}
118
+ return {kind: "none"}
119
+ }