@mentra/engine 3.1.0-dev.4 → 3.1.0-dev.42

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 (103) hide show
  1. package/README.md +19 -5
  2. package/build/bluetooth-sdk/ota-transport.d.ts +2 -0
  3. package/build/bluetooth-sdk/ota-transport.d.ts.map +1 -0
  4. package/build/bluetooth-sdk/ota-transport.js +2 -0
  5. package/build/bluetooth-sdk/ota-transport.js.map +1 -0
  6. package/build/engine.d.ts +5 -2
  7. package/build/engine.d.ts.map +1 -1
  8. package/build/facades/ota.d.ts +9 -6
  9. package/build/facades/ota.d.ts.map +1 -1
  10. package/build/facades/ota.js +6 -4
  11. package/build/facades/ota.js.map +1 -1
  12. package/build/facades/pairing.d.ts +4 -0
  13. package/build/facades/pairing.d.ts.map +1 -1
  14. package/build/facades/pairing.js +81 -7
  15. package/build/facades/pairing.js.map +1 -1
  16. package/build/generated/releaseMetadata.js +5 -5
  17. package/build/generated/releaseMetadata.js.map +1 -1
  18. package/build/index.d.ts +1 -1
  19. package/build/index.d.ts.map +1 -1
  20. package/build/index.js.map +1 -1
  21. package/build/react/MentraLiveOtaFlow.d.ts +3 -2
  22. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
  23. package/build/react/MentraLiveOtaFlow.js +113 -352
  24. package/build/react/MentraLiveOtaFlow.js.map +1 -1
  25. package/build/react/index.d.ts +1 -0
  26. package/build/react/index.d.ts.map +1 -1
  27. package/build/react/index.js +1 -0
  28. package/build/react/index.js.map +1 -1
  29. package/build/react/useMentraLiveOta.d.ts +80 -0
  30. package/build/react/useMentraLiveOta.d.ts.map +1 -0
  31. package/build/react/useMentraLiveOta.js +550 -0
  32. package/build/react/useMentraLiveOta.js.map +1 -0
  33. package/build/services/BlobStore.d.ts +2 -0
  34. package/build/services/BlobStore.d.ts.map +1 -1
  35. package/build/services/BlobStore.js +63 -10
  36. package/build/services/BlobStore.js.map +1 -1
  37. package/build/services/GlassesStatusProjection.d.ts +15 -2
  38. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  39. package/build/services/GlassesStatusProjection.js +3 -3
  40. package/build/services/GlassesStatusProjection.js.map +1 -1
  41. package/build/services/HotspotOtaTransport.d.ts.map +1 -1
  42. package/build/services/HotspotOtaTransport.js +8 -7
  43. package/build/services/HotspotOtaTransport.js.map +1 -1
  44. package/build/services/OtaAutoChain.d.ts +9 -2
  45. package/build/services/OtaAutoChain.d.ts.map +1 -1
  46. package/build/services/OtaAutoChain.js +10 -2
  47. package/build/services/OtaAutoChain.js.map +1 -1
  48. package/build/services/OtaInstallCoordinator.d.ts +24 -3
  49. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  50. package/build/services/OtaInstallCoordinator.js +14 -12
  51. package/build/services/OtaInstallCoordinator.js.map +1 -1
  52. package/build/services/OtaService.d.ts.map +1 -1
  53. package/build/services/OtaService.js +1 -1
  54. package/build/services/OtaService.js.map +1 -1
  55. package/build/services/OtaUpdateCheckService.d.ts +4 -1
  56. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  57. package/build/services/OtaUpdateCheckService.js +21 -5
  58. package/build/services/OtaUpdateCheckService.js.map +1 -1
  59. package/build/services/asg/localNetworkTransport.d.ts.map +1 -1
  60. package/build/services/asg/localNetworkTransport.js +13 -12
  61. package/build/services/asg/localNetworkTransport.js.map +1 -1
  62. package/build/services/glassesClockSync.js +1 -1
  63. package/build/services/glassesClockSync.js.map +1 -1
  64. package/build/services/otaReleaseVersion.d.ts +10 -0
  65. package/build/services/otaReleaseVersion.d.ts.map +1 -0
  66. package/build/services/otaReleaseVersion.js +14 -0
  67. package/build/services/otaReleaseVersion.js.map +1 -0
  68. package/build/stores/appVisibility.d.ts +3 -0
  69. package/build/stores/appVisibility.d.ts.map +1 -0
  70. package/build/stores/appVisibility.js +3 -0
  71. package/build/stores/appVisibility.js.map +1 -0
  72. package/build/stores/apps.d.ts.map +1 -1
  73. package/build/stores/apps.js +7 -1
  74. package/build/stores/apps.js.map +1 -1
  75. package/build/stores/core.d.ts +3 -3
  76. package/build/stores/core.d.ts.map +1 -1
  77. package/build/stores/core.js.map +1 -1
  78. package/build/stores/glasses.d.ts +10 -4
  79. package/build/stores/glasses.d.ts.map +1 -1
  80. package/build/stores/glasses.js.map +1 -1
  81. package/package.json +12 -6
  82. package/src/bluetooth-sdk/ota-transport.ts +1 -0
  83. package/src/facades/ota.ts +16 -5
  84. package/src/facades/pairing.ts +88 -12
  85. package/src/generated/releaseMetadata.ts +5 -5
  86. package/src/index.ts +1 -0
  87. package/src/react/MentraLiveOtaFlow.tsx +178 -465
  88. package/src/react/index.ts +14 -0
  89. package/src/react/useMentraLiveOta.ts +707 -0
  90. package/src/services/BlobStore.ts +57 -10
  91. package/src/services/GlassesStatusProjection.ts +7 -6
  92. package/src/services/HotspotOtaTransport.ts +8 -7
  93. package/src/services/OtaAutoChain.ts +26 -2
  94. package/src/services/OtaInstallCoordinator.ts +16 -13
  95. package/src/services/OtaService.ts +1 -2
  96. package/src/services/OtaUpdateCheckService.ts +26 -8
  97. package/src/services/asg/localNetworkTransport.ts +20 -19
  98. package/src/services/glassesClockSync.ts +1 -1
  99. package/src/services/otaReleaseVersion.ts +22 -0
  100. package/src/stores/appVisibility.ts +3 -0
  101. package/src/stores/apps.ts +7 -1
  102. package/src/stores/core.ts +4 -4
  103. package/src/stores/glasses.ts +13 -6
@@ -40,8 +40,15 @@ const LOG_TAG = "LocalMiniappRuntime"
40
40
  /** Per-app blob quota: 1 GB. */
41
41
  export const BLOB_QUOTA_BYTES = 1024 * 1024 * 1024
42
42
  const ROOT_DIR_NAME = "mentra_blobs"
43
- /** Cache dir root holding short-lived, properly-named copies handed to the OS share sheet (one unique subdir per share). */
43
+ /** Cache dir root holding short-lived, properly-named copies handed to the OS share sheet (one subdir per stored blob). */
44
44
  const SHARE_DIR_NAME = "mentra_blob_share"
45
+ /**
46
+ * Keep prepared share handoffs alive long enough for recipient apps to read
47
+ * them asynchronously (for example, an email composer attaching the file).
48
+ * Stale copies are collected before the next share; the OS may evict cache
49
+ * files sooner under storage pressure.
50
+ */
51
+ const SHARE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000
45
52
  const META_KEY_ROOT = "mentraos_blobmeta_"
46
53
  /** Cap md5 computation so we don't block the JS thread hashing a huge file. */
47
54
  const MD5_MAX_BYTES = 50 * 1024 * 1024
@@ -598,6 +605,32 @@ export class BlobStore {
598
605
 
599
606
  // ---- share ---------------------------------------------------------------
600
607
 
608
+ /** Best-effort GC for prepared share handoffs retained in the OS cache. */
609
+ private cleanupShareCache(now = Date.now()): void {
610
+ try {
611
+ const root = new Directory(Paths.cache, SHARE_DIR_NAME)
612
+ if (!root.exists) return
613
+ const cutoff = now - SHARE_CACHE_MAX_AGE_MS
614
+ for (const entry of root.list()) {
615
+ if (!(entry instanceof Directory)) continue
616
+ try {
617
+ const info = entry.info()
618
+ // modificationTime is available on both platforms; creationTime can
619
+ // be absent on Android versions before API 26. The directory id
620
+ // begins with Date.now().toString(36), which is a final fallback.
621
+ const encoded = entry.name.split("-", 1)[0]
622
+ const encodedTime = /^[0-9a-z]+$/.test(encoded) ? Number.parseInt(encoded, 36) : Number.NaN
623
+ const timestamp = info.modificationTime ?? info.creationTime ?? encodedTime
624
+ if (Number.isFinite(timestamp) && timestamp < cutoff) entry.delete()
625
+ } catch {
626
+ // Keep scanning if one cache entry disappears or is unreadable.
627
+ }
628
+ }
629
+ } catch {
630
+ // Cache cleanup must never prevent a share.
631
+ }
632
+ }
633
+
601
634
  async handleShare(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
602
635
  const key = payload.key as string
603
636
  const meta = this.readMeta(packageName, key)
@@ -615,23 +648,33 @@ export class BlobStore {
615
648
  // applies to base64/data payloads, not file:// URLs), so sharing `file.uri`
616
649
  // directly would hand the recipient a machine-named file. Copy to a temp
617
650
  // file that carries the real display name + extension, share that, then
618
- // clean it up.
651
+ // clean it up after the recipient has had time to consume it.
619
652
  const shareName = shareFileName(meta)
620
- // Copy into a per-share unique subdir so two concurrent shares that resolve
621
- // to the same display name can't clobber each other's temp file while
622
- // Share.open still references it. The file keeps `shareName` as its basename
623
- // so the OS share sheet shows the right name + extension.
653
+ // Key the cache directory by the immutable stored filename. Repeated or
654
+ // concurrent shares of the same blob can safely reference the same bytes,
655
+ // avoiding an extra full-sized cache copy for every share. Different blobs
656
+ // still get isolated directories even when they use the same display name.
657
+ // The file keeps `shareName` as its basename so the OS share sheet shows the
658
+ // right name + extension.
624
659
  let tempDir: Directory | null = null
660
+ let cacheReady = false
625
661
  try {
626
- tempDir = new Directory(Paths.cache, SHARE_DIR_NAME, this.makeId())
627
- tempDir.create({intermediates: true})
662
+ this.cleanupShareCache()
663
+ tempDir = new Directory(Paths.cache, SHARE_DIR_NAME, sanitizeSegment(meta.fileName))
664
+ if (!tempDir.exists) tempDir.create({intermediates: true})
628
665
  const temp = new File(tempDir, shareName)
629
- file.copy(temp)
666
+ if (!temp.exists || temp.size !== meta.bytes) {
667
+ if (temp.exists) temp.delete()
668
+ file.copy(temp)
669
+ }
670
+ cacheReady = true
630
671
  await Share.open({
631
672
  url: temp.uri,
632
673
  type: meta.mimeType || OCTET,
633
674
  filename: shareName,
634
675
  })
676
+ // Share.open resolves once the OS accepts the handoff. Recipient apps may
677
+ // still read the URI asynchronously, so retain this cache copy for GC.
635
678
  this.hooks.sendResult(packageName, requestId, true, {success: true})
636
679
  } catch (error: any) {
637
680
  if (error?.message?.includes("User did not share")) {
@@ -641,7 +684,11 @@ export class BlobStore {
641
684
  this.hooks.sendResult(packageName, requestId, true, {success: false})
642
685
  }
643
686
  } finally {
644
- if (tempDir) {
687
+ // Once a reusable cache entry is ready, retain it even if this particular
688
+ // sheet is cancelled or fails: another concurrent/recent share may still
689
+ // be reading the same file. Only remove an entry whose preparation did
690
+ // not finish; ready entries are collected after the retention window.
691
+ if (tempDir && !cacheReady) {
645
692
  try {
646
693
  if (tempDir.exists) tempDir.delete()
647
694
  } catch {
@@ -11,17 +11,18 @@
11
11
  *
12
12
  * Started by `engine.start()`. Idempotent.
13
13
  */
14
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
15
- import type {GlassesStatus} from "@mentra/bluetooth-sdk/internal"
14
+ import BluetoothSdk, {type PublicGlassesStatus} from "@mentra/bluetooth-sdk"
16
15
  import {useCoreStore} from "../stores/core"
17
16
  import {useGlassesStore} from "../stores/glasses"
18
17
 
19
18
  let unsubs: Array<() => void> = []
20
19
  let projectionRunId = 0
21
- let glassesStatusForwarder: ((status: Partial<GlassesStatus>) => void) | null = null
20
+ let glassesStatusForwarder: ((status: Partial<PublicGlassesStatus>) => void) | null = null
22
21
  let hydrationPromise: Promise<void> | null = null
23
22
 
24
- export function startGlassesStatusProjection(forwarder?: (status: Partial<GlassesStatus>) => void): Promise<void> {
23
+ export function startGlassesStatusProjection(
24
+ forwarder?: (status: Partial<PublicGlassesStatus>) => void,
25
+ ): Promise<void> {
25
26
  if (forwarder) glassesStatusForwarder = forwarder
26
27
  if (unsubs.length) return hydrationPromise ?? Promise.resolve()
27
28
 
@@ -49,7 +50,7 @@ export function startGlassesStatusProjection(forwarder?: (status: Partial<Glasse
49
50
 
50
51
  // Bluetooth-adapter status -> core store.
51
52
  unsubs.push(
52
- BluetoothSdk.onBluetoothStatus((changed) => {
53
+ BluetoothSdk.subscribeBluetoothStatus((changed) => {
53
54
  bluetoothEventSeen = true
54
55
  useCoreStore.getState().setCoreInfo(changed)
55
56
  }),
@@ -58,7 +59,7 @@ export function startGlassesStatusProjection(forwarder?: (status: Partial<Glasse
58
59
  // Glasses status -> glasses store (+ optional full-runtime forwarding to local
59
60
  // miniapps; clear any stale OTA-available flag on disconnect).
60
61
  unsubs.push(
61
- BluetoothSdk.onGlassesStatus((changed) => {
62
+ BluetoothSdk.subscribeGlassesStatus((changed) => {
62
63
  glassesEventSeen = true
63
64
  useGlassesStore.getState().setGlassesInfo(changed)
64
65
  glassesStatusForwarder?.(changed)
@@ -1,4 +1,5 @@
1
- import BluetoothSdk, {isEnabledHotspotStatus, MentraOtaServer} from "@mentra/bluetooth-sdk/internal"
1
+ import BluetoothSdk, {isEnabledHotspotStatus} from "@mentra/bluetooth-sdk"
2
+ import {otaServer} from "@mentra/bluetooth-sdk/ota-transport"
2
3
  // One coordinator selects the native Android or iOS staging implementation at runtime.
3
4
  // eslint-disable-next-line react-native/split-platform-components
4
5
  import {PermissionsAndroid, Platform} from "react-native"
@@ -83,7 +84,7 @@ class HotspotOtaTransport {
83
84
  let localAddress = scopedAddress
84
85
  if (Platform.OS === "ios") {
85
86
  try {
86
- localAddress = await MentraOtaServer.waitForWifiAddress(hotspot.localIp, 15_000)
87
+ localAddress = await otaServer.waitForWifiAddress(hotspot.localIp, 15_000)
87
88
  } catch (error) {
88
89
  throw new HotspotOtaTransportError(
89
90
  "hotspot_join_failed",
@@ -94,10 +95,10 @@ class HotspotOtaTransport {
94
95
  const artifactPaths = Object.fromEntries(this.prepared.map((artifact) => [artifact.sha256, artifact.filePath]))
95
96
  // Start exactly one native listener to learn its selected port, then atomically replace
96
97
  // the placeholder with the immutable rewritten manifest before ota_start is sent.
97
- const server = await MentraOtaServer.startOtaServer("{}", artifactPaths, localAddress)
98
+ const server = await otaServer.start("{}", artifactPaths, localAddress)
98
99
  this.serverStarted = true
99
100
  const manifest = rewriteManifestForLocalServer(checkResult.manifestBody, this.prepared, server.baseUrl)
100
- const published = await MentraOtaServer.startOtaServer(manifest, artifactPaths, server.host)
101
+ const published = await otaServer.start(manifest, artifactPaths, server.host)
101
102
  if (published.manifestUrl !== server.manifestUrl) {
102
103
  throw new Error("Local OTA server endpoint changed while publishing the manifest")
103
104
  }
@@ -141,11 +142,11 @@ class HotspotOtaTransport {
141
142
  destination: string,
142
143
  onProgress?: (bytesWritten: number, contentLength: number) => void,
143
144
  ): Promise<{statusCode: number}> => {
144
- const subscription = MentraOtaServer.addListener("artifactDownloadProgress", (event) => {
145
+ const subscription = otaServer.onArtifactDownloadProgress((event) => {
145
146
  if (event.destination === destination) onProgress?.(event.bytesWritten, event.contentLength)
146
147
  })
147
148
  try {
148
- return await MentraOtaServer.downloadArtifact(entry.url, destination)
149
+ return await otaServer.downloadArtifact(entry.url, destination)
149
150
  } finally {
150
151
  subscription.remove()
151
152
  }
@@ -164,7 +165,7 @@ class HotspotOtaTransport {
164
165
  console.warn("[OTA_PROGRESS] glasses hotspot shutdown was not confirmed after bounded retries")
165
166
  }
166
167
  }
167
- if (this.serverStarted) await MentraOtaServer.stopOtaServer().catch(() => {})
168
+ if (this.serverStarted) await otaServer.stop().catch(() => {})
168
169
  if (this.localNetworkConnected) await localNetworkTransport.disconnect().catch(() => {})
169
170
  await cleanupArtifacts().catch(() => {})
170
171
  this.prepared = []
@@ -6,10 +6,18 @@ export const OTA_AUTO_CHAIN_RECONNECT_TIMEOUT_MS = 120_000
6
6
  type OtaAutoChainSession = {
7
7
  approvedDowngrade: boolean
8
8
  passCount: number
9
+ releaseRange: OtaAutoChainReleaseRange
9
10
  reconnectDeadline: number | null
10
11
  seenFingerprints: Set<string>
11
12
  }
12
13
 
14
+ export type OtaAutoChainReleaseRange = {
15
+ fromVersion: string | null
16
+ toVersion: string | null
17
+ /** Exact coordinated release identity for the OTA pin, absent for legacy manifests. */
18
+ releaseVersion?: string | null
19
+ }
20
+
13
21
  export type OtaAutoChainAdvanceResult =
14
22
  | {advance: true; passCount: number}
15
23
  | {advance: false; reason: "inactive" | "duplicate" | "max_passes" | "downgrade_not_approved"}
@@ -42,10 +50,15 @@ export function otaAutoChainFingerprint(result: OtaCheckCurrentGlassesResult): s
42
50
  }
43
51
 
44
52
  /** Start a chain after the user explicitly approves its first update pass. */
45
- export function beginOtaAutoChain(initialFingerprint: string, approvedDowngrade: boolean): void {
53
+ export function beginOtaAutoChain(
54
+ initialFingerprint: string,
55
+ approvedDowngrade: boolean,
56
+ releaseRange: OtaAutoChainReleaseRange,
57
+ ): void {
46
58
  session = {
47
59
  approvedDowngrade,
48
60
  passCount: 1,
61
+ releaseRange: {...releaseRange},
49
62
  reconnectDeadline: null,
50
63
  seenFingerprints: new Set([initialFingerprint]),
51
64
  }
@@ -55,6 +68,10 @@ export function isOtaAutoChainActive(): boolean {
55
68
  return session !== null
56
69
  }
57
70
 
71
+ export function otaAutoChainReleaseRange(): OtaAutoChainReleaseRange | null {
72
+ return session ? {...session.releaseRange} : null
73
+ }
74
+
58
75
  /** End the current chain. Safe to call when no chain is active. */
59
76
  export function stopOtaAutoChain(): void {
60
77
  session = null
@@ -80,7 +97,12 @@ export function clearOtaAutoChainReconnectWait(): void {
80
97
  * Admit the next pass of an active chain. Repeated offers, unexpectedly
81
98
  * destructive downgrades, and runaway chains fail closed and end automation.
82
99
  */
83
- export function tryAdvanceOtaAutoChain(fingerprint: string, isDowngrade: boolean): OtaAutoChainAdvanceResult {
100
+ export function tryAdvanceOtaAutoChain(
101
+ fingerprint: string,
102
+ isDowngrade: boolean,
103
+ targetVersion: string | null,
104
+ releaseVersion: string | null = null,
105
+ ): OtaAutoChainAdvanceResult {
84
106
  if (!session) {
85
107
  return {advance: false, reason: "inactive"}
86
108
  }
@@ -102,5 +124,7 @@ export function tryAdvanceOtaAutoChain(fingerprint: string, isDowngrade: boolean
102
124
 
103
125
  session.seenFingerprints.add(fingerprint)
104
126
  session.passCount += 1
127
+ if (targetVersion) session.releaseRange.toVersion = targetVersion
128
+ if (releaseVersion) session.releaseRange.releaseVersion = releaseVersion
105
129
  return {advance: true, passCount: session.passCount}
106
130
  }
@@ -17,8 +17,7 @@
17
17
  * state written mid-pass queues a follow-up pass — the same way a setState
18
18
  * during React effects scheduled a re-render after the current effect batch.
19
19
  */
20
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
21
- import type {OtaProgress, OtaStatus} from "@mentra/bluetooth-sdk"
20
+ import BluetoothSdk, {type OtaProgress, type OtaStatus} from "@mentra/bluetooth-sdk"
22
21
  import GlobalEventEmitter from "../utils/GlobalEventEmitter"
23
22
  import {isGlassesConnected, useGlassesStore} from "../stores/glasses"
24
23
  import {resolveOtaManifestUrl} from "./otaManifestUrl"
@@ -170,6 +169,8 @@ export interface OtaInstallSnapshot {
170
169
  /** Pre-ota_start phone staging/join state for a hotspot attempt. */
171
170
  hotspotPhase: HotspotOtaPhase
172
171
  hotspotArtifactPercent: number | null
172
+ /** Transport selected from the checked glasses capabilities and Wi-Fi state. */
173
+ transport: "wifi" | "hotspot"
173
174
  }
174
175
 
175
176
  type OtaStartOutcome = "pending" | "acknowledged" | "rejected"
@@ -407,7 +408,7 @@ class OtaInstallCoordinator {
407
408
  if (this.otaStartOwnership?.outcome === "pending") {
408
409
  void this.sendOtaStartWithWatchdogs()
409
410
  } else {
410
- void BluetoothSdk.sendOtaQueryStatus().catch(() => {})
411
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
411
412
  this.armQueryReplyFallback("retry")
412
413
  }
413
414
  }
@@ -449,16 +450,17 @@ class OtaInstallCoordinator {
449
450
  * stays in the screen): clear the selected update, and after an APK step
450
451
  * clear the stale build number so the next check re-reads version_info.
451
452
  */
452
- finish(): void {
453
+ async finish(): Promise<void> {
453
454
  if (this.otaStartOwnership?.outcome !== "pending") {
454
455
  this.otaStartOwnership = null
455
456
  }
456
457
  useGlassesStore.getState().setOtaUpdateAvailable(null)
457
458
  if (this.apkStepSeen) {
458
- BluetoothSdk.updateGlasses({buildNumber: ""})
459
459
  useGlassesStore.getState().setGlassesInfo({buildNumber: ""})
460
460
  }
461
- void this.teardownHotspotTransport()
461
+ // The post-install check needs internet again. Do not let callers leave the
462
+ // terminal screen while Android still owns the no-internet hotspot route.
463
+ await this.teardownHotspotTransport()
462
464
  }
463
465
 
464
466
  /**
@@ -467,8 +469,8 @@ class OtaInstallCoordinator {
467
469
  * deriveDisplayState doesn't resurrect the stale install when the host
468
470
  * navigates away and back through /ota routes.
469
471
  */
470
- discard(): void {
471
- this.finish()
472
+ async discard(): Promise<void> {
473
+ await this.finish()
472
474
  const store = useGlassesStore.getState()
473
475
  store.setOtaStatus(null)
474
476
  store.setOtaProgress(null)
@@ -505,6 +507,7 @@ class OtaInstallCoordinator {
505
507
  versionChangePhase: this.deriveVersionChangePhase(connected),
506
508
  hotspotPhase: this.hotspotPhase,
507
509
  hotspotArtifactPercent: this.hotspotArtifactPercent,
510
+ transport: this.selectedTransport,
508
511
  }
509
512
  }
510
513
 
@@ -1065,7 +1068,7 @@ class OtaInstallCoordinator {
1065
1068
  if (this.otaStartOwnership?.outcome === "pending") {
1066
1069
  void this.sendOtaStartWithWatchdogs()
1067
1070
  }
1068
- void BluetoothSdk.sendOtaQueryStatus()
1071
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1069
1072
  this.armQueryReplyFallback("reconnect")
1070
1073
  return true
1071
1074
  }
@@ -1109,7 +1112,7 @@ class OtaInstallCoordinator {
1109
1112
  if (noSessionYet) {
1110
1113
  if (!isIdleStatus && this.otaStartOwnership?.outcome === "acknowledged") {
1111
1114
  console.log("[OTA_PROGRESS] initial mount, acknowledged session has no cached status — reconciling")
1112
- void BluetoothSdk.sendOtaQueryStatus().catch(() => {})
1115
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1113
1116
  this.armQueryReplyFallback("initial-mount")
1114
1117
  return
1115
1118
  }
@@ -1125,7 +1128,7 @@ class OtaInstallCoordinator {
1125
1128
  void this.sendOtaStartWithWatchdogs()
1126
1129
  } else {
1127
1130
  console.log("[OTA_PROGRESS] initial mount, session exists, sending ota_query_status")
1128
- void BluetoothSdk.sendOtaQueryStatus()
1131
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1129
1132
  this.armQueryReplyFallback("initial-mount")
1130
1133
  }
1131
1134
  }
@@ -1154,7 +1157,7 @@ class OtaInstallCoordinator {
1154
1157
  this.clearProgressTimeout()
1155
1158
  this.onFirstActivity()
1156
1159
  this.onFirstNonZeroProgress()
1157
- void BluetoothSdk.sendOtaQueryStatus()
1160
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1158
1161
  useGlassesStore.getState().setMtkUpdatedThisSession(true)
1159
1162
  }
1160
1163
 
@@ -1345,7 +1348,7 @@ class OtaInstallCoordinator {
1345
1348
  const s = useGlassesStore.getState().otaStatus
1346
1349
  if (s?.stepType === "apk" && s.phase === "install" && s.status === "in_progress") {
1347
1350
  this.apkInstallPollInFlight = true
1348
- void BluetoothSdk.sendOtaQueryStatus()
1351
+ void BluetoothSdk.queryOtaStatus()
1349
1352
  .catch(() => {})
1350
1353
  .finally(() => {
1351
1354
  this.apkInstallPollInFlight = false
@@ -10,8 +10,7 @@
10
10
  *
11
11
  * Started by `engine.start()`. Idempotent.
12
12
  */
13
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
14
- import type {OtaStatus} from "@mentra/bluetooth-sdk/internal"
13
+ import BluetoothSdk, {type OtaStatus} from "@mentra/bluetooth-sdk"
15
14
  import GlobalEventEmitter from "../utils/GlobalEventEmitter"
16
15
  import {useGlassesStore} from "../stores/glasses"
17
16
  import {handleOtaClockSkewFromGlasses} from "./glassesClockSync"
@@ -1,8 +1,9 @@
1
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
2
- import type {OtaUpdateInfo} from "@mentra/bluetooth-sdk"
1
+ import BluetoothSdk, {type OtaUpdateInfo} from "@mentra/bluetooth-sdk"
2
+ import {ENGINE_RELEASE_METADATA} from "../generated/releaseMetadata"
3
3
  import {getGlassesSystemTimeMs, isGlassesConnected, useGlassesStore, waitForGlassesState} from "../stores/glasses"
4
4
  import {maybeFixGlassesClockFromVersionInfo} from "./glassesClockSync"
5
5
  import {hasConfiguredModernOtaManifestPin, resolveOtaManifestUrl} from "./otaManifestUrl"
6
+ import {resolveOtaReleaseVersion} from "./otaReleaseVersion"
6
7
 
7
8
  /**
8
9
  * Phone-side mirror of ASG's `DOWNGRADE_FLOOR_VERSION_CODE`. Set to the versionCode of the first
@@ -38,6 +39,7 @@ export interface BesFirmware {
38
39
  }
39
40
 
40
41
  export interface VersionJson {
42
+ releaseVersion?: string
41
43
  apps?: {
42
44
  [packageName: string]: VersionInfo
43
45
  }
@@ -66,6 +68,8 @@ export interface OtaCheckResult {
66
68
  * instead of racing it with a second fetch of the same URL.
67
69
  */
68
70
  manifestBody: string | null
71
+ /** Coordinated release identity for the selected OTA pin. */
72
+ releaseVersion: string | null
69
73
  /**
70
74
  * Why the check failed, when it did. "network" is retryable (connection/server
71
75
  * trouble); "pin_unavailable" is permanent for this app build (manifest 404/gone,
@@ -106,6 +110,7 @@ function emptyCheckResult(skippedReason?: OtaCheckSkippedReason): OtaCheckCurren
106
110
  besVersion: null,
107
111
  isApkDowngrade: false,
108
112
  manifestBody: null,
113
+ releaseVersion: null,
109
114
  updateInfo: null,
110
115
  isRequired: true,
111
116
  skippedReason,
@@ -122,7 +127,7 @@ async function fetchVersionInfoDetailed(url: string): Promise<ManifestFetchResul
122
127
  try {
123
128
  const response = await fetch(url)
124
129
  if (!response.ok) {
125
- console.error("Failed to fetch version info:", response.status)
130
+ console.warn("Failed to fetch version info:", response.status)
126
131
  // 4xx: the manifest does not exist (or is gone) — permanent for this app
127
132
  // build, retrying cannot help. Everything else is transient server/network
128
133
  // trouble and retryable.
@@ -134,11 +139,11 @@ async function fetchVersionInfoDetailed(url: string): Promise<ManifestFetchResul
134
139
  try {
135
140
  return {json: await response.json()}
136
141
  } catch (parseError) {
137
- console.error("OTA: manifest is not valid JSON:", parseError)
142
+ console.warn("OTA: manifest is not valid JSON:", parseError)
138
143
  return {json: null, failureReason: "pin_unavailable"}
139
144
  }
140
145
  } catch (error) {
141
- console.error("OTA: Error fetching version info:", error)
146
+ console.warn("OTA: Error fetching version info:", error)
142
147
  return {json: null, failureReason: "network"}
143
148
  }
144
149
  }
@@ -298,6 +303,7 @@ export async function checkForOtaUpdate(
298
303
  besVersion: null,
299
304
  isApkDowngrade: false,
300
305
  manifestBody: null,
306
+ releaseVersion: null,
301
307
  checkFailureReason: fetched.failureReason ?? "network",
302
308
  }
303
309
  }
@@ -329,6 +335,7 @@ export async function checkForOtaUpdate(
329
335
  besVersion: null,
330
336
  isApkDowngrade: false,
331
337
  manifestBody: null,
338
+ releaseVersion: null,
332
339
  checkFailureReason: "pin_unavailable",
333
340
  }
334
341
  }
@@ -367,6 +374,12 @@ export async function checkForOtaUpdate(
367
374
  besVersion: versionJson?.bes_firmware?.version || null,
368
375
  isApkDowngrade: apkDirection === "downgrade",
369
376
  manifestBody: versionJson ? JSON.stringify(versionJson) : null,
377
+ releaseVersion: resolveOtaReleaseVersion({
378
+ manifestReleaseVersion: versionJson.releaseVersion,
379
+ manifestUrl: otaVersionUrl,
380
+ packagedManifestUrl: ENGINE_RELEASE_METADATA.otaManifestUrl,
381
+ packagedReleaseIdentity: ENGINE_RELEASE_METADATA.releaseIdentity,
382
+ }),
370
383
  }
371
384
  } catch (error) {
372
385
  console.error("Error checking for OTA update:", error)
@@ -379,6 +392,7 @@ export async function checkForOtaUpdate(
379
392
  besVersion: null,
380
393
  isApkDowngrade: false,
381
394
  manifestBody: null,
395
+ releaseVersion: null,
382
396
  checkFailureReason: "network",
383
397
  }
384
398
  }
@@ -406,9 +420,13 @@ export async function checkCurrentGlassesForUpdate(
406
420
  }
407
421
 
408
422
  if (refreshVersionInfo) {
409
- void BluetoothSdk.requestVersionInfo().catch((error) => {
410
- console.warn("OTA: Failed to request version_info from glasses:", error)
411
- })
423
+ void BluetoothSdk.requestVersionInfo()
424
+ .then((versionInfo) => {
425
+ useGlassesStore.getState().setGlassesInfo(versionInfo)
426
+ })
427
+ .catch((error) => {
428
+ console.warn("OTA: Failed to request version_info from glasses:", error)
429
+ })
412
430
  }
413
431
 
414
432
  let buildNumber = useGlassesStore.getState().buildNumber
@@ -1,5 +1,5 @@
1
1
  import * as RNFS from "@dr.pogodin/react-native-fs"
2
- import {MentraLocalNetwork, type LocalNetworkDownloadProgress} from "@mentra/bluetooth-sdk/internal"
2
+ import {otaLocalNetwork, type OtaLocalNetworkDownloadProgress} from "@mentra/bluetooth-sdk/ota-transport"
3
3
  import {Buffer} from "buffer"
4
4
  import {Platform} from "react-native"
5
5
  import WifiManager from "react-native-wifi-reborn"
@@ -17,7 +17,7 @@ export function shouldUseScopedLocalNetwork(os: string, moduleAvailable: boolean
17
17
  }
18
18
 
19
19
  function supportsScopedConnection(): boolean {
20
- return shouldUseScopedLocalNetwork(Platform.OS, MentraLocalNetwork != null)
20
+ return shouldUseScopedLocalNetwork(Platform.OS, otaLocalNetwork.isAvailable())
21
21
  }
22
22
 
23
23
  function normalizeHeaders(headers?: HeadersInit): Record<string, string> {
@@ -48,25 +48,25 @@ export const localNetworkTransport = {
48
48
  await WifiManager.connectToProtectedSSID(ssid, password, false, false)
49
49
  return undefined
50
50
  }
51
- const result = await MentraLocalNetwork!.connect(ssid, password)
51
+ const result = await otaLocalNetwork.connect(ssid, password)
52
52
  scopedConnectionActive = true
53
53
  return result.localAddress
54
54
  },
55
55
 
56
56
  async disconnect(): Promise<void> {
57
57
  if (supportsScopedConnection()) {
58
- await MentraLocalNetwork!.disconnect().catch(() => {})
58
+ await otaLocalNetwork.disconnect().catch(() => {})
59
59
  }
60
60
  scopedConnectionActive = false
61
61
  nativeJobs.clear()
62
62
  },
63
63
 
64
64
  async fetch(url: string | URL | Request, init?: RequestInit, timeoutMs = 30_000): Promise<Response> {
65
- if (!scopedConnectionActive || !MentraLocalNetwork || typeof url !== "string") {
65
+ if (!scopedConnectionActive || !otaLocalNetwork.isAvailable() || typeof url !== "string") {
66
66
  return globalThis.fetch(url, init)
67
67
  }
68
68
 
69
- const nativeModule = MentraLocalNetwork
69
+ const nativeModule = otaLocalNetwork
70
70
  const id = requestId("request")
71
71
  const signal = init?.signal
72
72
  const cancel = () => void nativeModule.cancel(id)
@@ -92,13 +92,13 @@ export const localNetworkTransport = {
92
92
  },
93
93
 
94
94
  downloadFile(options: DownloadOptions): DownloadHandle {
95
- if (!scopedConnectionActive || !MentraLocalNetwork) return RNFS.downloadFile(options)
95
+ if (!scopedConnectionActive || !otaLocalNetwork.isAvailable()) return RNFS.downloadFile(options)
96
96
 
97
97
  const jobId = nextJobId++
98
98
  const id = requestId("download")
99
99
  let began = false
100
100
  nativeJobs.set(jobId, id)
101
- const subscription = MentraLocalNetwork.addListener("downloadProgress", (event: LocalNetworkDownloadProgress) => {
101
+ const subscription = otaLocalNetwork.onDownloadProgress((event: OtaLocalNetworkDownloadProgress) => {
102
102
  if (event.requestId !== id) return
103
103
  if (!began) {
104
104
  began = true
@@ -116,14 +116,15 @@ export const localNetworkTransport = {
116
116
  })
117
117
  })
118
118
 
119
- const promise = MentraLocalNetwork.download(
120
- id,
121
- options.fromUrl,
122
- options.toFile,
123
- options.headers || {},
124
- options.connectionTimeout || 30_000,
125
- options.readTimeout || 30_000,
126
- )
119
+ const promise = otaLocalNetwork
120
+ .download(
121
+ id,
122
+ options.fromUrl,
123
+ options.toFile,
124
+ options.headers || {},
125
+ options.connectionTimeout || 30_000,
126
+ options.readTimeout || 30_000,
127
+ )
127
128
  .then((result) => {
128
129
  if (!began) {
129
130
  options.begin?.({
@@ -154,16 +155,16 @@ export const localNetworkTransport = {
154
155
 
155
156
  stopDownload(jobId: number): void {
156
157
  const nativeId = nativeJobs.get(jobId)
157
- if (nativeId && MentraLocalNetwork) {
158
+ if (nativeId && otaLocalNetwork.isAvailable()) {
158
159
  cancelledNativeJobs.add(jobId)
159
- void MentraLocalNetwork.cancel(nativeId)
160
+ void otaLocalNetwork.cancel(nativeId)
160
161
  return
161
162
  }
162
163
  RNFS.stopDownload(jobId)
163
164
  },
164
165
  }
165
166
 
166
- MentraLocalNetwork?.addListener("networkLost", () => {
167
+ otaLocalNetwork.onNetworkLost(() => {
167
168
  scopedConnectionActive = false
168
169
  nativeJobs.clear()
169
170
  })
@@ -2,7 +2,7 @@
2
2
  * Push phone time to glasses only when clock skew is detected (gallery sync, OTA, etc.).
3
3
  */
4
4
 
5
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
5
+ import BluetoothSdk from "@mentra/bluetooth-sdk"
6
6
  import {BgTimer} from "../utils/timers"
7
7
  import {useGlassesStore} from "../stores/glasses"
8
8
 
@@ -0,0 +1,22 @@
1
+ const COORDINATED_RELEASE_VERSION = /^\d+\.\d+\.\d+(?:-(?:dev|beta)\.[1-9]\d*)?$/
2
+
3
+ type OtaReleaseVersionInput = {
4
+ manifestReleaseVersion?: string | null
5
+ manifestUrl: string
6
+ packagedManifestUrl?: string | null
7
+ packagedReleaseIdentity?: string | null
8
+ }
9
+
10
+ function validReleaseVersion(version: string | null | undefined): string | null {
11
+ const trimmed = version?.trim()
12
+ return trimmed && COORDINATED_RELEASE_VERSION.test(trimmed) ? trimmed : null
13
+ }
14
+
15
+ /** Resolve the label for the selected OTA pin, including stable promotion of exact beta OTA bytes. */
16
+ export function resolveOtaReleaseVersion(input: OtaReleaseVersionInput): string | null {
17
+ const packagedRelease = validReleaseVersion(input.packagedReleaseIdentity)
18
+ if (packagedRelease && input.packagedManifestUrl?.trim() === input.manifestUrl.trim()) {
19
+ return packagedRelease
20
+ }
21
+ return validReleaseVersion(input.manifestReleaseVersion)
22
+ }
@@ -0,0 +1,3 @@
1
+ /** Combine registry-enforced visibility with the user's home-screen choice. */
2
+ export const resolveHiddenStatus = (registryHidden: boolean, userHidden: boolean): boolean =>
3
+ registryHidden || userHidden
@@ -25,6 +25,7 @@ import {islandNotifications} from "../services/NotificationsEmitter"
25
25
  import sttModelManager from "../services/STTModelManager"
26
26
  import {miniappLauncher} from "../services/MiniappLauncher"
27
27
  import {miniappRunningRegistry} from "../services/MiniappRunningRegistry"
28
+ import {resolveHiddenStatus} from "./appVisibility"
28
29
  import {SETTINGS, useSettingsStore} from "./settings"
29
30
  import BluetoothSdk from "@mentra/bluetooth-sdk"
30
31
 
@@ -221,7 +222,12 @@ function projectApps(previousState: AppStatusState, localApps: ClientApp[]): Cli
221
222
  ...app,
222
223
  screenshot: previousByPackage.get(app.packageName)?.screenshot ?? app.screenshot,
223
224
  compatibility: HardwareCompatibility.checkCompatibility(app.hardwareRequirements, capabilities),
224
- hidden: previousState.getHiddenStatus(app.packageName),
225
+ // A registry source can force an app off the primary home screen (for
226
+ // example, Miniapp Developer while its home-screen setting is disabled).
227
+ // Compose that gate with the user's persisted home-screen layout instead
228
+ // of replacing it, otherwise a default-false persisted value makes a
229
+ // source-hidden app visible again during projection.
230
+ hidden: resolveHiddenStatus(app.hidden, previousState.getHiddenStatus(app.packageName)),
225
231
  // Derive the foreground flag from the store's single source of truth
226
232
  // (`foregroundedPackage`), NOT from the previous snapshot. Carrying it over
227
233
  // per-app meant a refresh that momentarily omitted an app (e.g. the