@mentra/engine 3.2.0-dev.137 → 3.2.0-dev.154

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 (92) hide show
  1. package/build/engine.d.ts.map +1 -1
  2. package/build/engine.js +15 -9
  3. package/build/engine.js.map +1 -1
  4. package/build/facades/ota.d.ts +1 -0
  5. package/build/facades/ota.d.ts.map +1 -1
  6. package/build/facades/ota.js +1 -0
  7. package/build/facades/ota.js.map +1 -1
  8. package/build/facades/reports.d.ts.map +1 -1
  9. package/build/facades/reports.js +3 -0
  10. package/build/facades/reports.js.map +1 -1
  11. package/build/generated/releaseMetadata.js +5 -5
  12. package/build/generated/releaseMetadata.js.map +1 -1
  13. package/build/index.d.ts +1 -1
  14. package/build/index.d.ts.map +1 -1
  15. package/build/index.js.map +1 -1
  16. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
  17. package/build/react/MentraLiveOtaFlow.js +12 -0
  18. package/build/react/MentraLiveOtaFlow.js.map +1 -1
  19. package/build/react/useMentraLiveOta.d.ts +3 -1
  20. package/build/react/useMentraLiveOta.d.ts.map +1 -1
  21. package/build/react/useMentraLiveOta.js +19 -1
  22. package/build/react/useMentraLiveOta.js.map +1 -1
  23. package/build/runtime/bootstrap.d.ts +46 -3
  24. package/build/runtime/bootstrap.d.ts.map +1 -1
  25. package/build/runtime/bootstrap.js +60 -1
  26. package/build/runtime/bootstrap.js.map +1 -1
  27. package/build/runtime/config.d.ts +4 -0
  28. package/build/runtime/config.d.ts.map +1 -1
  29. package/build/runtime/config.js.map +1 -1
  30. package/build/runtime/streamConfig.d.ts.map +1 -1
  31. package/build/runtime/streamConfig.js +6 -0
  32. package/build/runtime/streamConfig.js.map +1 -1
  33. package/build/services/AppRegistry.d.ts +15 -1
  34. package/build/services/AppRegistry.d.ts.map +1 -1
  35. package/build/services/AppRegistry.js +48 -12
  36. package/build/services/AppRegistry.js.map +1 -1
  37. package/build/services/CloudClientService.d.ts +3 -1
  38. package/build/services/CloudClientService.d.ts.map +1 -1
  39. package/build/services/CloudClientService.js +75 -18
  40. package/build/services/CloudClientService.js.map +1 -1
  41. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  42. package/build/services/LocalMiniappRuntime.js +54 -12
  43. package/build/services/LocalMiniappRuntime.js.map +1 -1
  44. package/build/services/MiniappLauncher.d.ts.map +1 -1
  45. package/build/services/MiniappLauncher.js +9 -1
  46. package/build/services/MiniappLauncher.js.map +1 -1
  47. package/build/services/NavigationHandlers.d.ts.map +1 -1
  48. package/build/services/NavigationHandlers.js +8 -0
  49. package/build/services/NavigationHandlers.js.map +1 -1
  50. package/build/services/OtaUpdateCheckService.d.ts +10 -1
  51. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  52. package/build/services/OtaUpdateCheckService.js +24 -5
  53. package/build/services/OtaUpdateCheckService.js.map +1 -1
  54. package/build/services/miniappInstallIdentity.d.ts +10 -0
  55. package/build/services/miniappInstallIdentity.d.ts.map +1 -0
  56. package/build/services/miniappInstallIdentity.js +10 -0
  57. package/build/services/miniappInstallIdentity.js.map +1 -0
  58. package/build/services/otaManifestPolicy.d.ts +3 -0
  59. package/build/services/otaManifestPolicy.d.ts.map +1 -1
  60. package/build/services/otaManifestPolicy.js +8 -0
  61. package/build/services/otaManifestPolicy.js.map +1 -1
  62. package/build/services/otaManifestUrl.d.ts.map +1 -1
  63. package/build/services/otaManifestUrl.js +10 -2
  64. package/build/services/otaManifestUrl.js.map +1 -1
  65. package/build/stores/glasses.d.ts.map +1 -1
  66. package/build/stores/glasses.js +6 -0
  67. package/build/stores/glasses.js.map +1 -1
  68. package/build/stores/settings.d.ts.map +1 -1
  69. package/build/stores/settings.js +20 -3
  70. package/build/stores/settings.js.map +1 -1
  71. package/package.json +7 -7
  72. package/src/engine.ts +22 -12
  73. package/src/facades/ota.ts +1 -0
  74. package/src/facades/reports.ts +4 -6
  75. package/src/generated/releaseMetadata.ts +5 -5
  76. package/src/index.ts +2 -0
  77. package/src/react/MentraLiveOtaFlow.tsx +21 -0
  78. package/src/react/useMentraLiveOta.ts +23 -2
  79. package/src/runtime/bootstrap.ts +117 -4
  80. package/src/runtime/config.ts +4 -0
  81. package/src/runtime/streamConfig.ts +4 -0
  82. package/src/services/AppRegistry.ts +94 -25
  83. package/src/services/CloudClientService.ts +70 -24
  84. package/src/services/LocalMiniappRuntime.ts +85 -26
  85. package/src/services/MiniappLauncher.ts +15 -1
  86. package/src/services/NavigationHandlers.ts +8 -0
  87. package/src/services/OtaUpdateCheckService.ts +32 -6
  88. package/src/services/miniappInstallIdentity.ts +18 -0
  89. package/src/services/otaManifestPolicy.ts +11 -0
  90. package/src/services/otaManifestUrl.ts +10 -2
  91. package/src/stores/glasses.ts +6 -0
  92. package/src/stores/settings.ts +20 -3
@@ -14,6 +14,7 @@
14
14
  * this service.
15
15
  */
16
16
  import {CloudClient, setNativeHttp, setNativeUdp, setSecureStorage} from "@mentra/cloud-client/react-native"
17
+ import {DEFAULT_REFRESH_TOKEN_KEY} from "@mentra/cloud-client"
17
18
  import type {PreinstalledMiniappRegistry, RuntimeSnapshot} from "@mentra/cloud-client/react-native"
18
19
  import type {SubjectTokenType} from "@mentra/cloud-client"
19
20
  import {Platform} from "react-native"
@@ -21,7 +22,7 @@ import type {AudioSubscription, TranscriptionData, TranslationData} from "@mentr
21
22
 
22
23
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
23
24
  import CrustModule from "@mentra/crust"
24
- import {getAuth, getConfigValues} from "../runtime/bootstrap"
25
+ import {getAuth, getConfigValues, isFeatureEnabled} from "../runtime/bootstrap"
25
26
  import {useSettingsStore, SETTINGS} from "../stores/settings"
26
27
  import {type CloudClientStatusSnapshot, type MiniappAuthToken} from "../runtime/config"
27
28
  import {createCloudUdpSocket} from "../utils/cloudClient/RnUdpAdapter"
@@ -69,7 +70,7 @@ let persistentFailureNotified = false
69
70
  let audioSubscriptions: AudioSubscription[] = []
70
71
  let transportsReady = false
71
72
  /** Endpoints to build with — seeded from config, overridable via reconnect(). */
72
- let endpointsOverride: {core: string; runtime: string} | null = null
73
+ let endpointsOverride: {core?: string; runtime: string} | null = null
73
74
  let runtimeStatusUnsubscribe: (() => void) | null = null
74
75
  let transcriptUnsubscribe: (() => void) | null = null
75
76
  let translationUnsubscribe: (() => void) | null = null
@@ -98,12 +99,15 @@ const translationListeners = new Set<(d: TranslationData) => void>()
98
99
  const statusListeners = new Set<(snapshot: CloudClientStatusSnapshot) => void>()
99
100
  const connectionListeners = new Set<(connected: boolean) => void>()
100
101
 
101
- function resolveEndpoints(): {core: string; runtime: string} {
102
+ function resolveEndpoints(): {core?: string; runtime: string} {
102
103
  if (endpointsOverride) return endpointsOverride
103
104
  const cfg = getConfigValues()
105
+ const runtime = cfg.runtimeUrl === null ? "" : cfg.runtimeUrl?.trim() || FALLBACK_RUNTIME_URL
106
+ if (!runtime) throw new Error("cloudClient: Runtime endpoint is not configured")
107
+ const core = cfg.coreUrl === null ? undefined : cfg.coreUrl?.trim() || FALLBACK_CORE_URL
104
108
  return {
105
- core: cfg.coreUrl?.trim() || FALLBACK_CORE_URL,
106
- runtime: cfg.runtimeUrl?.trim() || FALLBACK_RUNTIME_URL,
109
+ ...(core ? {core} : {}),
110
+ runtime,
107
111
  }
108
112
  }
109
113
 
@@ -159,7 +163,7 @@ function frameSizeBytes(): Lc3FrameSizeBytes {
159
163
 
160
164
  async function getSubjectToken(): Promise<{token: string; type: SubjectTokenType}> {
161
165
  const a = getAuth()
162
- if (!a) throw new Error("cloudClient: engine.configure({auth}) not called")
166
+ if (!a?.getSubjectToken) throw new Error("cloudClient: Core subject-token auth is not configured")
163
167
  const r = await a.getSubjectToken()
164
168
  // IslandAuth's SubjectTokenType is intentionally open (`string & {}`) so OEMs
165
169
  // can use other token kinds; cloud-client's is a closed union. The host's
@@ -408,13 +412,23 @@ function construct(): void {
408
412
  const endpoints = resolveEndpoints()
409
413
  console.log(`${LOG_TAG}: endpoints ${JSON.stringify(endpoints)}`)
410
414
 
411
- const coreAuth = {getSubjectToken}
412
- const auth = shouldUseLocalDevRuntimeToken(endpoints)
415
+ const configuredAuth = getAuth()
416
+ if (!configuredAuth) throw new Error("cloudClient: engine.configure({auth}) not called")
417
+ const coreAuth = configuredAuth.getSubjectToken ? {getSubjectToken} : undefined
418
+ const auth = configuredAuth.getRuntimeToken
413
419
  ? {
414
- core: coreAuth,
415
- runtime: {getToken: (opts?: {forceRefresh?: boolean}) => getLocalDevRuntimeToken(endpoints.runtime, opts)},
420
+ ...(coreAuth && endpoints.core ? {core: coreAuth} : {}),
421
+ runtime: {getToken: configuredAuth.getRuntimeToken},
416
422
  }
417
- : {core: coreAuth, runtime: {source: "core" as const}}
423
+ : shouldUseLocalDevRuntimeToken(endpoints)
424
+ ? {
425
+ ...(coreAuth ? {core: coreAuth} : {}),
426
+ runtime: {getToken: (opts?: {forceRefresh?: boolean}) => getLocalDevRuntimeToken(endpoints.runtime, opts)},
427
+ }
428
+ : coreAuth && endpoints.core
429
+ ? {core: coreAuth, runtime: {source: "core" as const}}
430
+ : null
431
+ if (!auth) throw new Error("cloudClient: no Runtime authentication mode is configured")
418
432
 
419
433
  client = new CloudClient({
420
434
  endpoints,
@@ -422,6 +436,7 @@ function construct(): void {
422
436
  // LC3 at 16 kHz with the frame size the encoder emits.
423
437
  audio: {codec: "lc3", sampleRate: 16000, frameSizeBytes: frameSizeBytes()},
424
438
  auth,
439
+ authStorageKey: getConfigValues().cloudAuthStorageKey,
425
440
  timers: {
426
441
  setTimeout: (callback, delayMs) => BgTimer.setTimeout(callback, delayMs),
427
442
  clearTimeout: (handle) => BgTimer.clearTimeout(handle as number),
@@ -470,7 +485,9 @@ function construct(): void {
470
485
  connected = false
471
486
  console.log(`${LOG_TAG}: runtime disconnected (${info.reason})`)
472
487
  console.log(
473
- `${LOG_TAG}: debug: ws-session-debug disconnected reason=${info.reason} willStayDown=${/superseded by newer session/i.test(info.reason)}`,
488
+ `${LOG_TAG}: debug: ws-session-debug disconnected reason=${
489
+ info.reason
490
+ } willStayDown=${/superseded by newer session/i.test(info.reason)}`,
474
491
  )
475
492
  // Arm the persistent-failure alarm once; if we're still down when it fires, raise
476
493
  // the notification. A reconnect within the window cancels it (onConnected above).
@@ -493,17 +510,22 @@ function construct(): void {
493
510
  console.warn(`${LOG_TAG}: runtime error: ${err.code}`)
494
511
  })
495
512
 
496
- // Best-effort connect. Do not crash the app if the dev cloud is unreachable.
497
- c.runtime
498
- .connect()
499
- .then(() => console.log(`${LOG_TAG}: connect() resolved`))
500
- .catch((err) => console.warn(`${LOG_TAG}: connect() failed: ${err?.message ?? err}`))
501
-
502
- syncCoreAccessTokenToBluetooth().catch((err) =>
503
- console.warn(`${LOG_TAG}: initial Bluetooth core_token sync failed: ${(err as Error)?.message ?? err}`),
504
- )
513
+ if (getConfigValues().runtimeRealtimeSession !== false) {
514
+ // Best-effort connect. Do not crash the app if the dev cloud is unreachable.
515
+ c.runtime
516
+ .connect()
517
+ .then(() => console.log(`${LOG_TAG}: connect() resolved`))
518
+ .catch((err) => console.warn(`${LOG_TAG}: connect() failed: ${err?.message ?? err}`))
519
+ startLc3FrameSizeWatcher()
520
+ } else {
521
+ console.log(`${LOG_TAG}: Runtime live session disabled; REST capabilities remain available`)
522
+ }
505
523
 
506
- startLc3FrameSizeWatcher()
524
+ if (c.core) {
525
+ syncCoreAccessTokenToBluetooth().catch((err) =>
526
+ console.warn(`${LOG_TAG}: initial Bluetooth core_token sync failed: ${(err as Error)?.message ?? err}`),
527
+ )
528
+ }
507
529
  }
508
530
 
509
531
  /**
@@ -511,6 +533,14 @@ function construct(): void {
511
533
  * and the runtime-hook wiring.
512
534
  */
513
535
  export const cloudClientService = {
536
+ async clearAuthSession(): Promise<void> {
537
+ if (client) {
538
+ await client.auth.clearSession()
539
+ return
540
+ }
541
+ await cloudSecureStore.delete(getConfigValues().cloudAuthStorageKey ?? DEFAULT_REFRESH_TOKEN_KEY)
542
+ },
543
+
514
544
  /**
515
545
  * Construct (once) + connect the client. Idempotent. Best-effort connect — a
516
546
  * failure is logged and the app keeps running. Requires
@@ -527,7 +557,7 @@ export const cloudClientService = {
527
557
  * a prior override and fall back to the boot config (so cleared/default cloud
528
558
  * URLs don't keep reconnecting to a stale override); omit to keep the current.
529
559
  */
530
- reconnect(endpoints?: {core: string; runtime: string} | null): void {
560
+ reconnect(endpoints?: {core?: string; runtime: string} | null): void {
531
561
  if (endpoints !== undefined) endpointsOverride = endpoints
532
562
  try {
533
563
  client?.runtime.close()
@@ -565,6 +595,8 @@ export const cloudClientService = {
565
595
  */
566
596
  async resolveMentraUserId(): Promise<string> {
567
597
  return localMiniappUserIdentity.resolve(async () => {
598
+ const hostIdentity = getAuth()?.getUserId
599
+ if (hostIdentity) return await hostIdentity()
568
600
  if (!client) this.init()
569
601
  const c = client
570
602
  if (!c) throw new Error("cloud client not initialized")
@@ -636,14 +668,17 @@ export const cloudClientService = {
636
668
 
637
669
  /** Managed stream (cloud-v2): provision ingest+playback on the runtime. */
638
670
  startManagedStream(opts: Record<string, unknown> = {}) {
671
+ if (!isFeatureEnabled("managedStreams")) throw new Error("managed streams are disabled by this deployment")
639
672
  if (!client) throw new Error("cloud client not connected")
640
673
  return client.runtime.startManagedStream(opts)
641
674
  },
642
675
  getManagedStreamStatus(streamId: string) {
676
+ if (!isFeatureEnabled("managedStreams")) throw new Error("managed streams are disabled by this deployment")
643
677
  if (!client) throw new Error("cloud client not connected")
644
678
  return client.runtime.getManagedStreamStatus(streamId)
645
679
  },
646
680
  stopManagedStream(streamId: string) {
681
+ if (!isFeatureEnabled("managedStreams")) throw new Error("managed streams are disabled by this deployment")
647
682
  if (!client) throw new Error("cloud client not connected")
648
683
  return client.runtime.stopManagedStream(streamId)
649
684
  },
@@ -695,7 +730,13 @@ export const cloudClientService = {
695
730
  },
696
731
 
697
732
  getCoreUrl(): string {
698
- return resolveEndpoints().core
733
+ const core = resolveEndpoints().core
734
+ if (!core) throw new Error("cloud client core is unavailable")
735
+ return core
736
+ },
737
+
738
+ hasCore(): boolean {
739
+ return Boolean(client?.core)
699
740
  },
700
741
 
701
742
  syncCoreTokenToBluetooth(): Promise<string> {
@@ -711,6 +752,7 @@ export const cloudClientService = {
711
752
 
712
753
  tts: {
713
754
  speak(text: string, options?: Parameters<CloudClient["runtime"]["tts"]["speak"]>[1]) {
755
+ if (!isFeatureEnabled("cloudSpeech")) throw new Error("cloud speech is disabled by this deployment")
714
756
  if (!client) throw new Error("cloud client not connected")
715
757
  return client.runtime.tts.speak(text, options)
716
758
  },
@@ -718,18 +760,22 @@ export const cloudClientService = {
718
760
 
719
761
  maps: {
720
762
  directions(req: Parameters<CloudClient["runtime"]["maps"]["directions"]>[0]) {
763
+ if (!isFeatureEnabled("navigation")) throw new Error("navigation is disabled by this deployment")
721
764
  if (!client) throw new Error("cloud client not connected")
722
765
  return client.runtime.maps.directions(req)
723
766
  },
724
767
  reverseGeocode(coord: Parameters<CloudClient["runtime"]["maps"]["reverseGeocode"]>[0]) {
768
+ if (!isFeatureEnabled("navigation")) throw new Error("navigation is disabled by this deployment")
725
769
  if (!client) throw new Error("cloud client not connected")
726
770
  return client.runtime.maps.reverseGeocode(coord)
727
771
  },
728
772
  placeAutocomplete(req: Parameters<CloudClient["runtime"]["maps"]["placeAutocomplete"]>[0]) {
773
+ if (!isFeatureEnabled("navigation")) throw new Error("navigation is disabled by this deployment")
729
774
  if (!client) throw new Error("cloud client not connected")
730
775
  return client.runtime.maps.placeAutocomplete(req)
731
776
  },
732
777
  placeDetails(req: Parameters<CloudClient["runtime"]["maps"]["placeDetails"]>[0]) {
778
+ if (!isFeatureEnabled("navigation")) throw new Error("navigation is disabled by this deployment")
733
779
  if (!client) throw new Error("cloud client not connected")
734
780
  return client.runtime.maps.placeDetails(req)
735
781
  },
@@ -66,7 +66,7 @@ import {
66
66
  type MiniappAuthToken,
67
67
  type TtsSynthesisResult,
68
68
  } from "../runtime/config"
69
- import {getAnalytics, getUiSeams} from "../runtime/bootstrap"
69
+ import {getAnalytics, getMiniappConfiguration, getUiSeams, isFeatureEnabled} from "../runtime/bootstrap"
70
70
  import {invokeScanQrSeam} from "../runtime/scanQrSeam"
71
71
  import {normalizeStreamAudioConfig, normalizeStreamVideoConfig, resolveCaptureAudio} from "../runtime/streamConfig"
72
72
  import {toLanguageHint} from "@mentra/cloud-protocol/languages"
@@ -76,7 +76,7 @@ import ttsModelManager from "./TTSModelManager"
76
76
  import {NavigationHandlers} from "./NavigationHandlers"
77
77
  import type {ClientApp} from "../types/applet"
78
78
  import {useAppStatusStore} from "../stores/apps"
79
- import {getDevAppAttestation, getDevAppSourcePackage} from "./AppRegistry"
79
+ import appRegistry, {getDevAppAttestation, getDevAppSourcePackage} from "./AppRegistry"
80
80
  import {resolveForegroundLocationPermission} from "./ForegroundLocationPermission"
81
81
  import {advanceMiniappPingLiveness} from "./MiniappLiveness"
82
82
  import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
@@ -189,14 +189,7 @@ const LOG_TAG = "LOCAL_MINIAPP"
189
189
  const DIAGNOSTIC_MAX_LIST_ITEMS = 100
190
190
  const DIAGNOSTIC_MAX_STRING_LENGTH = 512
191
191
 
192
- function diagnosticStringList(values: Iterable<string>): string[] {
193
- return [...values]
194
- .map((value) => value.slice(0, DIAGNOSTIC_MAX_STRING_LENGTH))
195
- .sort()
196
- .slice(0, DIAGNOSTIC_MAX_LIST_ITEMS)
197
- }
198
-
199
- const SYSTEM_MINIAPP_PACKAGES = new Set([
192
+ const SYSTEM_MINIAPP_PACKAGE_SET = new Set([
200
193
  "com.mentra.camera",
201
194
  "com.mentra.gallery",
202
195
  "com.mentra.settings",
@@ -207,6 +200,14 @@ const SYSTEM_MINIAPP_PACKAGES = new Set([
207
200
  "com.mentra.feedback",
208
201
  "com.mentra.miniappdev",
209
202
  ])
203
+
204
+ function diagnosticStringList(values: Iterable<string>): string[] {
205
+ return [...values]
206
+ .map((value) => value.slice(0, DIAGNOSTIC_MAX_STRING_LENGTH))
207
+ .sort()
208
+ .slice(0, DIAGNOSTIC_MAX_LIST_ITEMS)
209
+ }
210
+
210
211
  const PING_INTERVAL_MS = 5_000
211
212
  const MINIAPP_AUTH_REFRESH_HEADROOM_MS = 5 * 60 * 1000
212
213
  const MINIAPP_AUTH_REFRESH_MIN_DELAY_MS = 5_000
@@ -1445,6 +1446,7 @@ class LocalMiniappRuntime {
1445
1446
  packageName,
1446
1447
  capabilities,
1447
1448
  permissions: declaredPermissions,
1449
+ configuration: getMiniappConfiguration(packageName),
1448
1450
  hostFeatures: {captureAudio: true},
1449
1451
  ...(initialAuth ? {auth: initialAuth} : {}),
1450
1452
  },
@@ -1468,6 +1470,9 @@ class LocalMiniappRuntime {
1468
1470
  }
1469
1471
 
1470
1472
  private async requestMiniappAuth(packageName: string, opts?: {minTtlMs?: number}): Promise<MiniappAuthToken | null> {
1473
+ // Core owns miniapp-backend token minting. A Runtime-only deployment has
1474
+ // no such capability, so fail once instead of entering the retry loop.
1475
+ if (!cloudClientService.hasCore()) return null
1471
1476
  const authPackageName = getDevAppSourcePackage(packageName) ?? packageName
1472
1477
  const devAttestation = getDevAppAttestation(packageName) ?? undefined
1473
1478
  return cloudClientService.getMiniappAuthToken(authPackageName, {
@@ -1705,6 +1710,34 @@ class LocalMiniappRuntime {
1705
1710
  return
1706
1711
  }
1707
1712
 
1713
+ for (const subscription of rawStreams ?? []) {
1714
+ const stream = typeof subscription === "string" ? subscription : subscription.stream
1715
+ if (stream.startsWith("translation:") && !isFeatureEnabled("cloudSpeech")) {
1716
+ this.sendResult(packageName, requestId, false, undefined, {
1717
+ code: MiniappErrorCode.NOT_IMPLEMENTED,
1718
+ message: "Cloud speech is disabled by this deployment",
1719
+ })
1720
+ return
1721
+ }
1722
+ if (!stream.startsWith("transcription:")) continue
1723
+ const forceLocal = typeof subscription === "object" && subscription.forceLocal === true
1724
+ const includeCloud = typeof subscription === "object" && subscription.includeCloud === true
1725
+ if (forceLocal && !isFeatureEnabled("onDeviceSpeech")) {
1726
+ this.sendResult(packageName, requestId, false, undefined, {
1727
+ code: MiniappErrorCode.NOT_IMPLEMENTED,
1728
+ message: "On-device speech is disabled by this deployment",
1729
+ })
1730
+ return
1731
+ }
1732
+ if ((!forceLocal || includeCloud) && !isFeatureEnabled("cloudSpeech")) {
1733
+ this.sendResult(packageName, requestId, false, undefined, {
1734
+ code: MiniappErrorCode.NOT_IMPLEMENTED,
1735
+ message: "Cloud speech is disabled by this deployment",
1736
+ })
1737
+ return
1738
+ }
1739
+ }
1740
+
1708
1741
  console.log(`${LOG_TAG}: SUBSCRIBE from ${packageName}: [${streams.join(", ")}]`)
1709
1742
 
1710
1743
  // Gate each stream on the permission type its data requires. The manifest
@@ -2244,11 +2277,11 @@ class LocalMiniappRuntime {
2244
2277
  typeof rawText === "string"
2245
2278
  ? [rawText.trim()].filter(Boolean)
2246
2279
  : Array.isArray(rawText)
2247
- ? rawText
2248
- .filter((sentence): sentence is string => typeof sentence === "string")
2249
- .map((sentence) => sentence.trim())
2250
- .filter(Boolean)
2251
- : []
2280
+ ? rawText
2281
+ .filter((sentence): sentence is string => typeof sentence === "string")
2282
+ .map((sentence) => sentence.trim())
2283
+ .filter(Boolean)
2284
+ : []
2252
2285
  const enableSanitization = payload.enableSanitization !== false
2253
2286
  const sentences = prepareTtsSentences(rawSentences, enableSanitization)
2254
2287
  if (sentences.length === 0) {
@@ -2333,6 +2366,7 @@ class LocalMiniappRuntime {
2333
2366
 
2334
2367
  const playOfflineTts = async (reason?: string): Promise<boolean> => {
2335
2368
  if (run.cancelled) return true
2369
+ if (!isFeatureEnabled("onDeviceSpeech")) return false
2336
2370
  if (!offlineSupportsVoice || !(await ttsModelManager.isModelAvailable())) {
2337
2371
  return false
2338
2372
  }
@@ -2577,7 +2611,9 @@ class LocalMiniappRuntime {
2577
2611
  const permission = await resolveForegroundLocationPermission(Location, () => AppState.currentState)
2578
2612
  const {status} = permission
2579
2613
  console.log(
2580
- `${LOG_TAG}: location poll permission status=${status} appState=${AppState.currentState} elapsed=${Date.now() - permissionStartedAt}ms`,
2614
+ `${LOG_TAG}: location poll permission status=${status} appState=${AppState.currentState} elapsed=${
2615
+ Date.now() - permissionStartedAt
2616
+ }ms`,
2581
2617
  )
2582
2618
  if (status !== "granted") {
2583
2619
  this.sendResult(packageName, requestId, false, undefined, {
@@ -3138,11 +3174,7 @@ class LocalMiniappRuntime {
3138
3174
  * session.system.scanQr — host camera overlay. Must not clear miniapp
3139
3175
  * foreground; the host seam is responsible for presenting a Modal on top.
3140
3176
  */
3141
- private async handleScanQr(
3142
- packageName: string,
3143
- payload: Record<string, unknown>,
3144
- requestId?: string,
3145
- ): Promise<void> {
3177
+ private async handleScanQr(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
3146
3178
  try {
3147
3179
  const result = await invokeScanQrSeam(getUiSeams().scanQr, payload)
3148
3180
  this.sendResult(packageName, requestId, true, result)
@@ -3465,7 +3497,15 @@ class LocalMiniappRuntime {
3465
3497
  return
3466
3498
  }
3467
3499
  // 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)) {
3500
+ if (
3501
+ !this.requireManifestPermission(
3502
+ packageName,
3503
+ requestId,
3504
+ "CAMERA",
3505
+ "to stream the camera",
3506
+ MiniappRequestType.STREAM_START,
3507
+ )
3508
+ ) {
3469
3509
  return
3470
3510
  }
3471
3511
  try {
@@ -3582,8 +3622,20 @@ class LocalMiniappRuntime {
3582
3622
  // A meeting puts the glasses camera and mic in front of remote strangers; it
3583
3623
  // needs both declared, same as photo/stream and mic capture do individually.
3584
3624
  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)
3625
+ !this.requireManifestPermission(
3626
+ packageName,
3627
+ requestId,
3628
+ "CAMERA",
3629
+ "to join a meeting",
3630
+ MiniappRequestType.MEETING_JOIN,
3631
+ ) ||
3632
+ !this.requireManifestPermission(
3633
+ packageName,
3634
+ requestId,
3635
+ "MICROPHONE",
3636
+ "to join a meeting",
3637
+ MiniappRequestType.MEETING_JOIN,
3638
+ )
3587
3639
  ) {
3588
3640
  return
3589
3641
  }
@@ -4333,8 +4385,15 @@ class LocalMiniappRuntime {
4333
4385
  }
4334
4386
 
4335
4387
  private isSystemPackage(packageName: string): boolean {
4336
- if (SYSTEM_MINIAPP_PACKAGES.has(packageName)) return true
4337
- return this.interopApps().find((app) => app.packageName === packageName)?.isMiniappDev === true
4388
+ const app = this.interopApps().find((candidate) => candidate.packageName === packageName)
4389
+ if (SYSTEM_MINIAPP_PACKAGE_SET.has(packageName)) {
4390
+ if (app?.offline) return true
4391
+ if (app?.local && app.version) {
4392
+ return appRegistry.getReleaseIdentity(packageName, app.version)?.source === "bundled_asset"
4393
+ }
4394
+ return false
4395
+ }
4396
+ return app?.isMiniappDev === true
4338
4397
  }
4339
4398
 
4340
4399
  private async startInteropApp(packageName: string): Promise<boolean> {
@@ -25,6 +25,7 @@
25
25
 
26
26
  import {File} from "expo-file-system"
27
27
 
28
+ import {isInstalledMiniappAllowed, isLocalMiniappPackageAllowed} from "../runtime/bootstrap"
28
29
  import {resolveDevBundleSource} from "../utils/devMiniappSnapshot"
29
30
  import {storage} from "../utils/storage/storage"
30
31
  import appRegistry, {getLocalAppRunningState, saveLocalAppRunningState} from "./AppRegistry"
@@ -134,7 +135,7 @@ class MiniappLauncher {
134
135
  if (!entry?.background) return null
135
136
 
136
137
  // 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
+ // after a laptop Wi-Fi change (mDNS / Metro failover inside resolveDevBundleSource).
138
139
  const base = resolvedUrl.replace(/\/$/, "")
139
140
  // entry.* are bundle-root paths (dist/ stripped); the dev server serves
140
141
  // files relative to cwd, so prepend dist/.
@@ -243,6 +244,9 @@ class MiniappLauncher {
243
244
  * {@link LocalMiniappRuntime.waitForConnect}.
244
245
  */
245
246
  async ensureRunning(packageName: string, hints?: LaunchHints): Promise<LaunchResult> {
247
+ if (!isLocalMiniappPackageAllowed(packageName)) {
248
+ throw new Error(`MiniappLauncher: ${packageName} is disabled by deployment policy`)
249
+ }
246
250
  const router = this.requireRouter()
247
251
 
248
252
  // Already spawned: best-effort resolve for the UI entry, never throw —
@@ -273,6 +277,16 @@ class MiniappLauncher {
273
277
  if (!resolved) {
274
278
  throw new Error(`MiniappLauncher: cannot resolve bundle for ${packageName}`)
275
279
  }
280
+ const version = resolved.installedManifest?.version
281
+ if (
282
+ !isInstalledMiniappAllowed(
283
+ packageName,
284
+ version,
285
+ version ? appRegistry.getReleaseIdentity(packageName, version) : null,
286
+ )
287
+ ) {
288
+ throw new Error(`MiniappLauncher: ${packageName} bundle is not authorized by deployment policy`)
289
+ }
276
290
 
277
291
  // Re-check after the async resolve: a different path may have spawned it
278
292
  // while we were fetching/reading the bundle.
@@ -26,6 +26,7 @@ import {type NavLocation, type NavRoute, type NavUpdate} from "../runtime/config
26
26
  import {cloudClientService} from "./CloudClientService"
27
27
  import navigationService from "./NavigationService"
28
28
  import {MiniappErrorCode, MiniappResponseType, MiniappStreamType} from "@mentra/miniapp"
29
+ import {isFeatureEnabled} from "../runtime/bootstrap"
29
30
 
30
31
  const LOG_TAG = "LocalMiniappRuntime"
31
32
 
@@ -81,6 +82,13 @@ export class NavigationHandlers {
81
82
  }
82
83
 
83
84
  async handleStart(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
85
+ if (!isFeatureEnabled("navigation")) {
86
+ this.sendResult(packageName, requestId, false, undefined, {
87
+ code: MiniappErrorCode.NOT_IMPLEMENTED,
88
+ message: "Navigation is disabled by this deployment",
89
+ })
90
+ return
91
+ }
84
92
  console.log(`${LOG_TAG}: handleNavigationStart from ${packageName}`, JSON.stringify(payload))
85
93
 
86
94
  // v2 wire shape sends `stops`; v1 shape sends bare lat/lng. Accept both.
@@ -78,7 +78,15 @@ export interface OtaCheckResult {
78
78
  checkFailureReason?: "network" | "pin_unavailable"
79
79
  }
80
80
 
81
- export type OtaCheckSkippedReason = "disconnected" | "missing_build" | "dev_build"
81
+ /**
82
+ * Package the stock glasses client installs as, and the key every apps-shaped manifest pins the
83
+ * ASG APK under. Android forces a distinct package (`.thirdparty` suffix) on any build not signed
84
+ * with Mentra's release key, so a client reporting anything else is a sideloaded build that
85
+ * coexists with the stock system app.
86
+ */
87
+ export const STOCK_ASG_PACKAGE = "com.mentra.asg_client"
88
+
89
+ export type OtaCheckSkippedReason = "disconnected" | "missing_build" | "dev_build" | "unofficial_client"
82
90
 
83
91
  export interface OtaCheckCurrentGlassesResult extends OtaCheckResult {
84
92
  updateInfo: OtaUpdateInfo | null
@@ -86,6 +94,8 @@ export interface OtaCheckCurrentGlassesResult extends OtaCheckResult {
86
94
  skippedReason?: OtaCheckSkippedReason
87
95
  manifestUrl?: string
88
96
  buildNumber?: string
97
+ /** Glasses client package, when it reported one. Set on an `unofficial_client` skip. */
98
+ packageName?: string
89
99
  mtkFirmwareVersion?: string
90
100
  besFirmwareVersion?: string
91
101
  }
@@ -100,7 +110,10 @@ export interface OtaCheckCurrentGlassesOptions {
100
110
  floorVersionCode?: number
101
111
  }
102
112
 
103
- function emptyCheckResult(skippedReason?: OtaCheckSkippedReason): OtaCheckCurrentGlassesResult {
113
+ function emptyCheckResult(
114
+ skippedReason?: OtaCheckSkippedReason,
115
+ extra?: Partial<OtaCheckCurrentGlassesResult>,
116
+ ): OtaCheckCurrentGlassesResult {
104
117
  return {
105
118
  hasCheckCompleted: false,
106
119
  updateAvailable: false,
@@ -114,6 +127,7 @@ function emptyCheckResult(skippedReason?: OtaCheckSkippedReason): OtaCheckCurren
114
127
  updateInfo: null,
115
128
  isRequired: true,
116
129
  skippedReason,
130
+ ...extra,
117
131
  }
118
132
  }
119
133
 
@@ -183,7 +197,7 @@ export function getApkUpdateDirection(
183
197
  let serverVersion: number | undefined
184
198
  let exactPin = false
185
199
 
186
- const appEntry = versionJson.apps?.["com.mentra.asg_client"]
200
+ const appEntry = versionJson.apps?.[STOCK_ASG_PACKAGE]
187
201
  if (appEntry) {
188
202
  serverVersion = appEntry.versionCode
189
203
  exactPin = true
@@ -212,8 +226,8 @@ export function getLatestVersionInfo(versionJson: VersionJson | null): VersionIn
212
226
  return null
213
227
  }
214
228
 
215
- if (versionJson.apps?.["com.mentra.asg_client"]) {
216
- return versionJson.apps["com.mentra.asg_client"]
229
+ if (versionJson.apps?.[STOCK_ASG_PACKAGE]) {
230
+ return versionJson.apps[STOCK_ASG_PACKAGE]
217
231
  }
218
232
 
219
233
  if (versionJson.versionCode) {
@@ -315,7 +329,7 @@ export async function checkForOtaUpdate(
315
329
  // than silently completing as "up to date" — an unverifiable state must never
316
330
  // present as a verified one.
317
331
  const currentVersionNumber = parseInt(currentBuildNumber, 10)
318
- const pinnedEntry = versionJson?.apps?.["com.mentra.asg_client"]
332
+ const pinnedEntry = versionJson?.apps?.[STOCK_ASG_PACKAGE]
319
333
  const pinnedVersion = pinnedEntry?.versionCode ?? versionJson?.versionCode
320
334
  if (
321
335
  versionJson &&
@@ -442,6 +456,18 @@ export async function checkCurrentGlassesForUpdate(
442
456
  return emptyCheckResult("missing_build")
443
457
  }
444
458
 
459
+ // A sideloaded ASG client installs under its own package (Android forces the `.thirdparty`
460
+ // suffix on any build not signed with Mentra's release key) and coexists with the stock system
461
+ // app. Its build number is therefore not comparable to the manifest's pin, and installing the
462
+ // manifest APK would replace the stock package while the sideloaded client keeps holding the
463
+ // link and reporting its own unchanged version — an update prompt that can never be satisfied.
464
+ // Absent means the glasses predate the field: assume stock, preserving existing behavior.
465
+ const packageName = useGlassesStore.getState().packageName
466
+ if (packageName && packageName !== STOCK_ASG_PACKAGE) {
467
+ console.log(`OTA: check skipped - glasses run an unofficial client (${packageName})`)
468
+ return emptyCheckResult("unofficial_client", {buildNumber, packageName})
469
+ }
470
+
445
471
  if (!glassesConnectedNow()) {
446
472
  return emptyCheckResult("disconnected")
447
473
  }
@@ -0,0 +1,18 @@
1
+ export interface MiniappInstallExpectations {
2
+ packageName?: string
3
+ version?: string
4
+ rejectExistingVersion?: boolean
5
+ }
6
+
7
+ export function miniappInstallIdentityError(
8
+ manifest: {packageName: string; version: string},
9
+ expected?: MiniappInstallExpectations,
10
+ ): string | null {
11
+ if (expected?.packageName && manifest.packageName !== expected.packageName) {
12
+ return `Bundle package mismatch: expected ${expected.packageName}, got ${manifest.packageName}`
13
+ }
14
+ if (expected?.version && manifest.version !== expected.version) {
15
+ return `Bundle version mismatch: expected ${expected.version}, got ${manifest.version}`
16
+ }
17
+ return null
18
+ }
@@ -40,3 +40,14 @@ export function resolveOtaManifestPolicy({
40
40
  }
41
41
  return selectModernOtaManifestPin({developerOverride, hostReleasePin, engineReleasePin})
42
42
  }
43
+
44
+ export function resolveDeploymentAwareOtaManifestPolicy({
45
+ hostPolicyConfigured,
46
+ ...policy
47
+ }: Parameters<typeof resolveOtaManifestPolicy>[0] & {hostPolicyConfigured: boolean}): string | null {
48
+ // A selected deployment is authoritative for every glasses generation. An
49
+ // explicit null is a deliberate OTA-off policy, not permission to inherit a
50
+ // device-reported URL or Mentra's legacy public default.
51
+ if (hostPolicyConfigured) return trimmed(policy.hostReleasePin)
52
+ return resolveOtaManifestPolicy(policy)
53
+ }
@@ -1,6 +1,7 @@
1
1
  import {ENGINE_RELEASE_METADATA} from "../generated/releaseMetadata"
2
2
  import {SETTINGS, useSettingsStore} from "../stores/settings"
3
- import {resolveOtaManifestPolicy, selectModernOtaManifestPin} from "./otaManifestPolicy"
3
+ import {resolveDeploymentAwareOtaManifestPolicy, selectModernOtaManifestPin} from "./otaManifestPolicy"
4
+ import {getConfigValues} from "../runtime/bootstrap"
4
5
 
5
6
  function getOtaVersionUrlDevOverride(): string | null {
6
7
  // Super mode only: a wrong OTA manifest can brick glasses, so a saved
@@ -14,10 +15,16 @@ function getOtaVersionUrlDevOverride(): string | null {
14
15
  }
15
16
 
16
17
  function getHostReleasePin(): string | null {
18
+ const configured = getConfigValues().otaManifestUrl
19
+ if (configured !== undefined) return configured?.trim() || null
17
20
  const value = process.env.EXPO_PUBLIC_ASG_OTA_VERSION_URL?.trim()
18
21
  return value || null
19
22
  }
20
23
 
24
+ function hasDeploymentOtaPolicy(): boolean {
25
+ return getConfigValues().otaManifestUrl !== undefined
26
+ }
27
+
21
28
  function getEmbeddedEngineReleasePin(): string | null {
22
29
  const value = ENGINE_RELEASE_METADATA.otaManifestUrl?.trim()
23
30
  return value || null
@@ -34,7 +41,8 @@ export function hasConfiguredModernOtaManifestPin(): boolean {
34
41
  }
35
42
 
36
43
  export function resolveOtaManifestUrl(glassesUrl?: string | null, glassesBuildNumber?: string | null): string | null {
37
- return resolveOtaManifestPolicy({
44
+ return resolveDeploymentAwareOtaManifestPolicy({
45
+ hostPolicyConfigured: hasDeploymentOtaPolicy(),
38
46
  glassesUrl,
39
47
  glassesBuildNumber,
40
48
  developerOverride: getOtaVersionUrlDevOverride(),