@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.
- package/README.md +19 -5
- package/build/bluetooth-sdk/ota-transport.d.ts +2 -0
- package/build/bluetooth-sdk/ota-transport.d.ts.map +1 -0
- package/build/bluetooth-sdk/ota-transport.js +2 -0
- package/build/bluetooth-sdk/ota-transport.js.map +1 -0
- package/build/engine.d.ts +5 -2
- package/build/engine.d.ts.map +1 -1
- package/build/facades/ota.d.ts +9 -6
- package/build/facades/ota.d.ts.map +1 -1
- package/build/facades/ota.js +6 -4
- package/build/facades/ota.js.map +1 -1
- package/build/facades/pairing.d.ts +4 -0
- package/build/facades/pairing.d.ts.map +1 -1
- package/build/facades/pairing.js +81 -7
- package/build/facades/pairing.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/react/MentraLiveOtaFlow.d.ts +3 -2
- package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
- package/build/react/MentraLiveOtaFlow.js +113 -352
- package/build/react/MentraLiveOtaFlow.js.map +1 -1
- package/build/react/index.d.ts +1 -0
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +1 -0
- package/build/react/index.js.map +1 -1
- package/build/react/useMentraLiveOta.d.ts +80 -0
- package/build/react/useMentraLiveOta.d.ts.map +1 -0
- package/build/react/useMentraLiveOta.js +550 -0
- package/build/react/useMentraLiveOta.js.map +1 -0
- package/build/services/BlobStore.d.ts +2 -0
- package/build/services/BlobStore.d.ts.map +1 -1
- package/build/services/BlobStore.js +63 -10
- package/build/services/BlobStore.js.map +1 -1
- package/build/services/GlassesStatusProjection.d.ts +15 -2
- package/build/services/GlassesStatusProjection.d.ts.map +1 -1
- package/build/services/GlassesStatusProjection.js +3 -3
- package/build/services/GlassesStatusProjection.js.map +1 -1
- package/build/services/HotspotOtaTransport.d.ts.map +1 -1
- package/build/services/HotspotOtaTransport.js +8 -7
- package/build/services/HotspotOtaTransport.js.map +1 -1
- package/build/services/OtaAutoChain.d.ts +9 -2
- package/build/services/OtaAutoChain.d.ts.map +1 -1
- package/build/services/OtaAutoChain.js +10 -2
- package/build/services/OtaAutoChain.js.map +1 -1
- package/build/services/OtaInstallCoordinator.d.ts +24 -3
- package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
- package/build/services/OtaInstallCoordinator.js +14 -12
- package/build/services/OtaInstallCoordinator.js.map +1 -1
- package/build/services/OtaService.d.ts.map +1 -1
- package/build/services/OtaService.js +1 -1
- package/build/services/OtaService.js.map +1 -1
- package/build/services/OtaUpdateCheckService.d.ts +4 -1
- package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
- package/build/services/OtaUpdateCheckService.js +21 -5
- package/build/services/OtaUpdateCheckService.js.map +1 -1
- package/build/services/asg/localNetworkTransport.d.ts.map +1 -1
- package/build/services/asg/localNetworkTransport.js +13 -12
- package/build/services/asg/localNetworkTransport.js.map +1 -1
- package/build/services/glassesClockSync.js +1 -1
- package/build/services/glassesClockSync.js.map +1 -1
- package/build/services/otaReleaseVersion.d.ts +10 -0
- package/build/services/otaReleaseVersion.d.ts.map +1 -0
- package/build/services/otaReleaseVersion.js +14 -0
- package/build/services/otaReleaseVersion.js.map +1 -0
- package/build/stores/appVisibility.d.ts +3 -0
- package/build/stores/appVisibility.d.ts.map +1 -0
- package/build/stores/appVisibility.js +3 -0
- package/build/stores/appVisibility.js.map +1 -0
- package/build/stores/apps.d.ts.map +1 -1
- package/build/stores/apps.js +7 -1
- package/build/stores/apps.js.map +1 -1
- package/build/stores/core.d.ts +3 -3
- package/build/stores/core.d.ts.map +1 -1
- package/build/stores/core.js.map +1 -1
- package/build/stores/glasses.d.ts +10 -4
- package/build/stores/glasses.d.ts.map +1 -1
- package/build/stores/glasses.js.map +1 -1
- package/package.json +12 -6
- package/src/bluetooth-sdk/ota-transport.ts +1 -0
- package/src/facades/ota.ts +16 -5
- package/src/facades/pairing.ts +88 -12
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +1 -0
- package/src/react/MentraLiveOtaFlow.tsx +178 -465
- package/src/react/index.ts +14 -0
- package/src/react/useMentraLiveOta.ts +707 -0
- package/src/services/BlobStore.ts +57 -10
- package/src/services/GlassesStatusProjection.ts +7 -6
- package/src/services/HotspotOtaTransport.ts +8 -7
- package/src/services/OtaAutoChain.ts +26 -2
- package/src/services/OtaInstallCoordinator.ts +16 -13
- package/src/services/OtaService.ts +1 -2
- package/src/services/OtaUpdateCheckService.ts +26 -8
- package/src/services/asg/localNetworkTransport.ts +20 -19
- package/src/services/glassesClockSync.ts +1 -1
- package/src/services/otaReleaseVersion.ts +22 -0
- package/src/stores/appVisibility.ts +3 -0
- package/src/stores/apps.ts +7 -1
- package/src/stores/core.ts +4 -4
- 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
|
|
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
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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
|
-
|
|
627
|
-
tempDir.
|
|
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
|
-
|
|
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
|
|
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
|
|
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<
|
|
20
|
+
let glassesStatusForwarder: ((status: Partial<PublicGlassesStatus>) => void) | null = null
|
|
22
21
|
let hydrationPromise: Promise<void> | null = null
|
|
23
22
|
|
|
24
|
-
export function startGlassesStatusProjection(
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
2
|
-
import
|
|
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.
|
|
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.
|
|
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.
|
|
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()
|
|
410
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
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 || !
|
|
65
|
+
if (!scopedConnectionActive || !otaLocalNetwork.isAvailable() || typeof url !== "string") {
|
|
66
66
|
return globalThis.fetch(url, init)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const nativeModule =
|
|
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 || !
|
|
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 =
|
|
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 =
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 &&
|
|
158
|
+
if (nativeId && otaLocalNetwork.isAvailable()) {
|
|
158
159
|
cancelledNativeJobs.add(jobId)
|
|
159
|
-
void
|
|
160
|
+
void otaLocalNetwork.cancel(nativeId)
|
|
160
161
|
return
|
|
161
162
|
}
|
|
162
163
|
RNFS.stopDownload(jobId)
|
|
163
164
|
},
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
|
|
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
|
|
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
|
+
}
|
package/src/stores/apps.ts
CHANGED
|
@@ -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
|
-
|
|
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
|