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