@glassly/engine 0.3.7-beta.889 → 0.3.7-beta.896
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/engine.d.ts +6 -0
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +3 -0
- package/build/engine.js.map +1 -1
- package/build/facades/backup.d.ts +14 -0
- package/build/facades/backup.d.ts.map +1 -0
- package/build/facades/backup.js +22 -0
- package/build/facades/backup.js.map +1 -0
- package/build/generated/releaseMetadata.js +3 -3
- 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.map +1 -1
- package/build/services/AppRegistry.d.ts +2 -1
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +9 -2
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/AppStateBackup.d.ts +98 -0
- package/build/services/AppStateBackup.d.ts.map +1 -0
- package/build/services/AppStateBackup.js +224 -0
- package/build/services/AppStateBackup.js.map +1 -0
- package/build/services/CloudClientService.d.ts +1 -0
- package/build/services/CloudClientService.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +18 -4
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MiniappEngine.d.ts +9 -0
- package/build/services/MiniappEngine.d.ts.map +1 -1
- package/build/services/MiniappEngine.js +60 -27
- package/build/services/MiniappEngine.js.map +1 -1
- package/build/services/NavigationService.d.ts +1 -0
- package/build/services/NavigationService.d.ts.map +1 -1
- package/build/services/NavigationService.js +20 -6
- package/build/services/NavigationService.js.map +1 -1
- package/build/services/appStateBackupFile.d.ts +32 -0
- package/build/services/appStateBackupFile.d.ts.map +1 -0
- package/build/services/appStateBackupFile.js +135 -0
- package/build/services/appStateBackupFile.js.map +1 -0
- package/build/utils/miniappGlobals.d.ts +13 -17
- package/build/utils/miniappGlobals.d.ts.map +1 -1
- package/build/utils/miniappGlobals.js +24 -56
- package/build/utils/miniappGlobals.js.map +1 -1
- package/package.json +7 -7
- package/src/engine.ts +3 -0
- package/src/facades/backup.ts +28 -0
- package/src/generated/releaseMetadata.ts +3 -3
- package/src/index.ts +1 -0
- package/src/services/AppRegistry.ts +8 -3
- package/src/services/AppStateBackup.ts +283 -0
- package/src/services/LocalMiniappRuntime.ts +21 -4
- package/src/services/MiniappEngine.ts +62 -27
- package/src/services/NavigationService.ts +28 -8
- package/src/services/appStateBackupFile.ts +171 -0
- package/src/utils/miniappGlobals.ts +32 -64
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App state backups (Settings > Profile > Backup): one JSON file carrying the
|
|
3
|
+
* system settings, every miniapp's SimpleStorage, the installed miniapp list
|
|
4
|
+
* with each app's home flags, and the memory timeline.
|
|
5
|
+
*
|
|
6
|
+
* It exists so a logout or reinstall doesn't mean signing back into every
|
|
7
|
+
* miniapp, which is why the file holds live credentials (miniapp tokens,
|
|
8
|
+
* provider API keys) as plain JSON. Pairing identity, cloud endpoints, and
|
|
9
|
+
* consent/login state stay out: they belong to the install and the account,
|
|
10
|
+
* not to the user's preferences.
|
|
11
|
+
*
|
|
12
|
+
* Storage keys are written without their user segment, so a backup restores
|
|
13
|
+
* into whatever namespace the phone has now (account or `local_`). Restore
|
|
14
|
+
* overwrites key by key and leaves keys the backup doesn't carry alone.
|
|
15
|
+
*
|
|
16
|
+
* Pure: the MMKV view, settings registry, and memory snapshot are passed in by
|
|
17
|
+
* appStateBackupFile.ts.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {MemoryBackup} from "@glassly/miniapp"
|
|
21
|
+
|
|
22
|
+
/** Key-value surface (MMKV in production, values already JSON-decoded). */
|
|
23
|
+
export interface AppStateBackupBackend {
|
|
24
|
+
get(key: string): unknown | null
|
|
25
|
+
set(key: string, value: unknown): void
|
|
26
|
+
keys(): string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The fields of a SETTINGS descriptor that decide whether it is backed up. */
|
|
30
|
+
export interface BackupSettingDescriptor {
|
|
31
|
+
key: string
|
|
32
|
+
persist: boolean
|
|
33
|
+
writable: boolean
|
|
34
|
+
nativeAuthoritative?: true
|
|
35
|
+
resetOnBuildEnvChange?: true
|
|
36
|
+
indexer?: (key: string) => string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AppStateBackupMiniapp {
|
|
40
|
+
/** Set when the miniapp was installed at export. */
|
|
41
|
+
installed?: {version: string; source?: string}
|
|
42
|
+
/** SimpleStorage key → stored value. */
|
|
43
|
+
storage: Record<string, unknown>
|
|
44
|
+
/** Home flags by suffix: `hidden`, `always_on`, `widget_expand_<widgetId>`. */
|
|
45
|
+
prefs: Record<string, unknown>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AppStateBackup {
|
|
49
|
+
format: "glassly-app-state"
|
|
50
|
+
version: 1
|
|
51
|
+
/** Epoch ms the backup was written. */
|
|
52
|
+
exportedAt: number
|
|
53
|
+
/** Stored setting values; indexed settings keep their `key:index` storage key. */
|
|
54
|
+
settings: Record<string, unknown>
|
|
55
|
+
miniapps: Record<string, AppStateBackupMiniapp>
|
|
56
|
+
memory: MemoryBackup | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Thrown for a file that isn't an app state backup this build can read. */
|
|
60
|
+
export class AppStateBackupError extends Error {
|
|
61
|
+
constructor(message: string) {
|
|
62
|
+
super(message)
|
|
63
|
+
this.name = "AppStateBackupError"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const STORAGE_ROOT = "localstorage_"
|
|
68
|
+
const PREF_SUFFIXES = ["hidden", "always_on"] as const
|
|
69
|
+
const WIDGET_EXPAND_PREFIX = "widget_expand_"
|
|
70
|
+
// Same rule as the miniapp.json schema; keeps a hand-edited file from writing keys no app reads.
|
|
71
|
+
const PACKAGE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Persisted, writable settings that are still not the user's to carry between
|
|
75
|
+
* installs: account and consent state, the paired devices, and caches.
|
|
76
|
+
*/
|
|
77
|
+
const EXCLUDED_SETTINGS = new Set([
|
|
78
|
+
"auth_email",
|
|
79
|
+
"disable_cloud",
|
|
80
|
+
"ai_data_consent_version",
|
|
81
|
+
"ai_data_consent_agreed",
|
|
82
|
+
"login_skipped",
|
|
83
|
+
"has_paired_glasses",
|
|
84
|
+
"keyboard_name",
|
|
85
|
+
"cached_required_version",
|
|
86
|
+
"offline_camera_running",
|
|
87
|
+
"offline_translation_running",
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
export function isBackupSetting(setting: BackupSettingDescriptor): boolean {
|
|
91
|
+
return (
|
|
92
|
+
setting.persist &&
|
|
93
|
+
setting.writable &&
|
|
94
|
+
!setting.nativeAuthoritative &&
|
|
95
|
+
!setting.resetOnBuildEnvChange &&
|
|
96
|
+
!EXCLUDED_SETTINGS.has(setting.key)
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CollectAppStateInput {
|
|
101
|
+
backend: AppStateBackupBackend
|
|
102
|
+
userId: string
|
|
103
|
+
settings: BackupSettingDescriptor[]
|
|
104
|
+
installed: Array<{packageName: string; version: string; source?: string}>
|
|
105
|
+
memory: MemoryBackup | null
|
|
106
|
+
now: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function collectAppStateBackup(input: CollectAppStateInput): AppStateBackup {
|
|
110
|
+
const {backend, settings} = input
|
|
111
|
+
const keys = backend.keys()
|
|
112
|
+
|
|
113
|
+
const settingValues: Record<string, unknown> = {}
|
|
114
|
+
for (const setting of settings) {
|
|
115
|
+
if (!isBackupSetting(setting)) continue
|
|
116
|
+
if (setting.indexer) {
|
|
117
|
+
for (const key of keys) {
|
|
118
|
+
if (!key.startsWith(`${setting.key}:`)) continue
|
|
119
|
+
const value = backend.get(key)
|
|
120
|
+
if (value != null) settingValues[key] = value
|
|
121
|
+
}
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const value = backend.get(setting.key)
|
|
125
|
+
if (value != null) settingValues[setting.key] = value
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const miniapps: Record<string, AppStateBackupMiniapp> = {}
|
|
129
|
+
const entry = (packageName: string) => (miniapps[packageName] ??= {storage: {}, prefs: {}})
|
|
130
|
+
|
|
131
|
+
for (const app of input.installed) {
|
|
132
|
+
const record = entry(app.packageName)
|
|
133
|
+
record.installed = app.source ? {version: app.version, source: app.source} : {version: app.version}
|
|
134
|
+
for (const suffix of PREF_SUFFIXES) {
|
|
135
|
+
const value = backend.get(`${app.packageName}_${suffix}`)
|
|
136
|
+
if (value != null) record.prefs[suffix] = value
|
|
137
|
+
}
|
|
138
|
+
const expandPrefix = `${app.packageName}_${WIDGET_EXPAND_PREFIX}`
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
if (!key.startsWith(expandPrefix)) continue
|
|
141
|
+
const value = backend.get(key)
|
|
142
|
+
if (value != null) record.prefs[key.slice(app.packageName.length + 1)] = value
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Longest first, so a package whose name extends another's still claims its own keys.
|
|
147
|
+
const known = input.installed.map((app) => app.packageName).sort((a, b) => b.length - a.length)
|
|
148
|
+
const prefix = `${STORAGE_ROOT}${input.userId}_`
|
|
149
|
+
for (const key of keys) {
|
|
150
|
+
if (!key.startsWith(prefix)) continue
|
|
151
|
+
const split = splitStorageKey(key.slice(prefix.length), known)
|
|
152
|
+
if (!split) continue
|
|
153
|
+
const value = backend.get(key)
|
|
154
|
+
if (value != null) entry(split.packageName).storage[split.key] = value
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
format: "glassly-app-state",
|
|
159
|
+
version: 1,
|
|
160
|
+
exportedAt: input.now,
|
|
161
|
+
settings: settingValues,
|
|
162
|
+
miniapps,
|
|
163
|
+
memory: input.memory,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `{packageName}_{key}` → its parts. Package names may contain `_`, so known names win over the first `_`. */
|
|
168
|
+
function splitStorageKey(rest: string, known: string[]): {packageName: string; key: string} | null {
|
|
169
|
+
const packageName = known.find((name) => rest.startsWith(`${name}_`)) ?? rest.slice(0, rest.indexOf("_"))
|
|
170
|
+
const key = rest.slice(packageName.length + 1)
|
|
171
|
+
if (!packageName || !key) return null
|
|
172
|
+
return {packageName, key}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function parseAppStateBackup(data: unknown): AppStateBackup {
|
|
176
|
+
if (!isRecord(data) || data.format !== "glassly-app-state") {
|
|
177
|
+
throw new AppStateBackupError("Not a Glassly app state backup")
|
|
178
|
+
}
|
|
179
|
+
if (typeof data.version !== "number" || data.version > 1) {
|
|
180
|
+
throw new AppStateBackupError(`Unsupported app state backup version ${String(data.version)}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const settings = isRecord(data.settings) ? data.settings : {}
|
|
184
|
+
const miniapps: Record<string, AppStateBackupMiniapp> = {}
|
|
185
|
+
if (isRecord(data.miniapps)) {
|
|
186
|
+
for (const [packageName, raw] of Object.entries(data.miniapps)) {
|
|
187
|
+
if (!PACKAGE_NAME_RE.test(packageName) || !isRecord(raw)) continue
|
|
188
|
+
const record: AppStateBackupMiniapp = {
|
|
189
|
+
storage: isRecord(raw.storage) ? raw.storage : {},
|
|
190
|
+
prefs: isRecord(raw.prefs) ? raw.prefs : {},
|
|
191
|
+
}
|
|
192
|
+
if (isRecord(raw.installed) && typeof raw.installed.version === "string") {
|
|
193
|
+
record.installed = {version: raw.installed.version}
|
|
194
|
+
if (typeof raw.installed.source === "string") record.installed.source = raw.installed.source
|
|
195
|
+
}
|
|
196
|
+
miniapps[packageName] = record
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let memory: MemoryBackup | null = null
|
|
201
|
+
if (data.memory != null) {
|
|
202
|
+
if (!isRecord(data.memory) || data.memory.format !== "glassly-memory") {
|
|
203
|
+
throw new AppStateBackupError("The backup's memory section is not a memory backup")
|
|
204
|
+
}
|
|
205
|
+
memory = data.memory as unknown as MemoryBackup
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {format: "glassly-app-state", version: 1, exportedAt: Number(data.exportedAt) || 0, settings, miniapps, memory}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The backup's settings this build will restore, split by how they're written:
|
|
213
|
+
* `plain` through the settings store, `indexed` (`key:index`) straight to storage.
|
|
214
|
+
* Keys this build doesn't know, or doesn't back up, are dropped.
|
|
215
|
+
*/
|
|
216
|
+
export function restorableSettings(
|
|
217
|
+
backup: AppStateBackup,
|
|
218
|
+
settings: BackupSettingDescriptor[],
|
|
219
|
+
): {plain: Record<string, unknown>; indexed: Record<string, unknown>} {
|
|
220
|
+
const byKey = new Map(settings.map((setting) => [setting.key, setting]))
|
|
221
|
+
const plain: Record<string, unknown> = {}
|
|
222
|
+
const indexed: Record<string, unknown> = {}
|
|
223
|
+
for (const [key, value] of Object.entries(backup.settings)) {
|
|
224
|
+
if (value == null) continue
|
|
225
|
+
const colon = key.indexOf(":")
|
|
226
|
+
const setting = byKey.get(colon < 0 ? key : key.slice(0, colon))
|
|
227
|
+
if (!setting || !isBackupSetting(setting)) continue
|
|
228
|
+
if (colon < 0 && !setting.indexer) plain[key] = value
|
|
229
|
+
else if (colon > 0 && setting.indexer) indexed[key] = value
|
|
230
|
+
}
|
|
231
|
+
return {plain, indexed}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Miniapps the backup has storage for. */
|
|
235
|
+
export function miniappsWithStorage(backup: AppStateBackup): string[] {
|
|
236
|
+
return Object.entries(backup.miniapps)
|
|
237
|
+
.filter(([, record]) => Object.entries(record.storage).some(([key, value]) => key && value != null))
|
|
238
|
+
.map(([packageName]) => packageName)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Write every miniapp's storage into `userId`'s namespace. Returns the packages that got keys. */
|
|
242
|
+
export function restoreMiniappStorage(
|
|
243
|
+
backend: AppStateBackupBackend,
|
|
244
|
+
userId: string,
|
|
245
|
+
backup: AppStateBackup,
|
|
246
|
+
): {packages: string[]; keys: number} {
|
|
247
|
+
const packages: string[] = []
|
|
248
|
+
let keys = 0
|
|
249
|
+
for (const [packageName, record] of Object.entries(backup.miniapps)) {
|
|
250
|
+
let wrote = 0
|
|
251
|
+
for (const [key, value] of Object.entries(record.storage)) {
|
|
252
|
+
if (!key || value == null) continue
|
|
253
|
+
backend.set(`${STORAGE_ROOT}${userId}_${packageName}_${key}`, value)
|
|
254
|
+
wrote++
|
|
255
|
+
}
|
|
256
|
+
if (wrote > 0) packages.push(packageName)
|
|
257
|
+
keys += wrote
|
|
258
|
+
}
|
|
259
|
+
return {packages, keys}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface MiniappPrefs {
|
|
263
|
+
hidden?: boolean
|
|
264
|
+
alwaysOn?: boolean
|
|
265
|
+
widgetExpand: Record<string, boolean>
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A miniapp's home flags from the backup, with anything that isn't a boolean dropped. */
|
|
269
|
+
export function miniappPrefs(record: AppStateBackupMiniapp): MiniappPrefs {
|
|
270
|
+
const prefs: MiniappPrefs = {widgetExpand: {}}
|
|
271
|
+
if (typeof record.prefs.hidden === "boolean") prefs.hidden = record.prefs.hidden
|
|
272
|
+
if (typeof record.prefs.always_on === "boolean") prefs.alwaysOn = record.prefs.always_on
|
|
273
|
+
for (const [key, value] of Object.entries(record.prefs)) {
|
|
274
|
+
if (!key.startsWith(WIDGET_EXPAND_PREFIX) || typeof value !== "boolean") continue
|
|
275
|
+
const widgetId = key.slice(WIDGET_EXPAND_PREFIX.length)
|
|
276
|
+
if (widgetId) prefs.widgetExpand[widgetId] = value
|
|
277
|
+
}
|
|
278
|
+
return prefs
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
282
|
+
return value != null && typeof value === "object" && !Array.isArray(value)
|
|
283
|
+
}
|
|
@@ -304,6 +304,20 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | nul
|
|
|
304
304
|
})
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/** Bounds the cold-GPS wait for a one-shot poll; on a stall, any older fix beats an error. */
|
|
308
|
+
const FRESH_LOCATION_TIMEOUT_MS = 8_000
|
|
309
|
+
|
|
310
|
+
async function freshLocationOrStale(): Promise<Location.LocationObject> {
|
|
311
|
+
const fresh = await withTimeout(
|
|
312
|
+
Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Low}),
|
|
313
|
+
FRESH_LOCATION_TIMEOUT_MS,
|
|
314
|
+
)
|
|
315
|
+
if (fresh) return fresh
|
|
316
|
+
const stale = await Location.getLastKnownPositionAsync()
|
|
317
|
+
if (stale) return stale
|
|
318
|
+
throw new Error("No location fix available")
|
|
319
|
+
}
|
|
320
|
+
|
|
307
321
|
const LOG_TAG = "LOCAL_MINIAPP"
|
|
308
322
|
const DIAGNOSTIC_MAX_LIST_ITEMS = 100
|
|
309
323
|
const DIAGNOSTIC_MAX_STRING_LENGTH = 512
|
|
@@ -3450,7 +3464,7 @@ class LocalMiniappRuntime {
|
|
|
3450
3464
|
const locationStartedAt = Date.now()
|
|
3451
3465
|
const cached = await Location.getLastKnownPositionAsync({maxAge: 60_000})
|
|
3452
3466
|
console.log(`${LOG_TAG}: location poll cache hit=${cached !== null} elapsed=${Date.now() - locationStartedAt}ms`)
|
|
3453
|
-
const location = cached ?? (await
|
|
3467
|
+
const location = cached ?? (await freshLocationOrStale())
|
|
3454
3468
|
console.log(`${LOG_TAG}: location poll resolved elapsed=${Date.now() - locationStartedAt}ms`)
|
|
3455
3469
|
|
|
3456
3470
|
this.sendResult(packageName, requestId, true, {
|
|
@@ -6071,13 +6085,16 @@ class LocalMiniappRuntime {
|
|
|
6071
6085
|
private async startInteropApp(packageName: string): Promise<boolean> {
|
|
6072
6086
|
const app = this.interopApps().find((a) => a.packageName === packageName)
|
|
6073
6087
|
if (!app) return false
|
|
6088
|
+
// session.miniapps.start comes from the glasses (the dashboard's Apps menu,
|
|
6089
|
+
// an Assist voice command), unlike a headless action wake, which carries no source.
|
|
6090
|
+
const launchSource = "glasses" as const
|
|
6074
6091
|
// Running only for its widget: start() promotes it to a normal run, as opening it on the phone does.
|
|
6075
|
-
if (app.widgetOnly) return useAppStatusStore.getState().start(app, {skipNavigation: true})
|
|
6092
|
+
if (app.widgetOnly) return useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
|
|
6076
6093
|
if (app.local) {
|
|
6077
|
-
await miniappLauncher.ensureConnected(packageName)
|
|
6094
|
+
await miniappLauncher.ensureConnected(packageName, undefined, {launchSource})
|
|
6078
6095
|
return true
|
|
6079
6096
|
}
|
|
6080
|
-
return useAppStatusStore.getState().start(app, {skipNavigation: true})
|
|
6097
|
+
return useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
|
|
6081
6098
|
}
|
|
6082
6099
|
|
|
6083
6100
|
private stopInteropApp(packageName: string): Promise<void> {
|
|
@@ -29,42 +29,77 @@ export interface MiniappEngine {
|
|
|
29
29
|
|
|
30
30
|
let engine: MiniappEngine | null = null
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Kill and re-spawn a miniapp's background JSContext from its installed bundle
|
|
34
|
+
* (dev: the dev server's fresh build). Resolves false when it isn't installed
|
|
35
|
+
* or the spawn failed.
|
|
36
|
+
*/
|
|
37
|
+
async function respawnBackground(router: JSRouter, uiRouter: UIRouter, packageName: string): Promise<boolean> {
|
|
38
|
+
// Re-resolve the freshly built bundle (dev: HTTP off the dev server;
|
|
39
|
+
// released: file:// snapshot) via the launcher's shared recipe. Carries
|
|
40
|
+
// the declared permissions + manifest across the respawn — omitting them
|
|
41
|
+
// would respawn the JSContext with no permissions, so SUBSCRIBE gates and
|
|
42
|
+
// per-call dispatch checks would start rejecting after a background
|
|
43
|
+
// hot-reload.
|
|
44
|
+
const resolved = await miniappLauncher.resolveBundle(packageName)
|
|
45
|
+
if (!resolved) {
|
|
46
|
+
console.warn(`JSRuntime: respawn-bg could not resolve bundle for ${packageName}`)
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
// Force a respawn (kill + spawn) rather than launcher.ensureRunning,
|
|
50
|
+
// which is idempotent and would no-op an already-registered context.
|
|
51
|
+
await router.unregister(packageName)
|
|
52
|
+
const ok = await router.spawnAndRegister(packageName, resolved.bgSource, {
|
|
53
|
+
permissions: resolved.declaredPermissions,
|
|
54
|
+
installedManifest: resolved.installedManifest,
|
|
55
|
+
})
|
|
56
|
+
if (!ok) {
|
|
57
|
+
console.warn(`JSRuntime: respawn-bg failed for ${packageName}`)
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
// The respawned JSContext is a fresh MiniappSession with ui.bound false.
|
|
61
|
+
// The mounted WebView won't re-fire glassly.ready() (it's latched), so
|
|
62
|
+
// re-announce it so RPC replies (glassly.request) reach the UI again
|
|
63
|
+
// instead of being dropped while unbound.
|
|
64
|
+
uiRouter.notifyReopen(packageName)
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
32
68
|
function wireDevServerRespawnBackground(router: JSRouter, uiRouter: UIRouter): void {
|
|
33
69
|
devServerBridge.onRespawnBackground(async (packageName) => {
|
|
34
70
|
try {
|
|
35
|
-
|
|
36
|
-
// released: file:// snapshot) via the launcher's shared recipe. Carries
|
|
37
|
-
// the declared permissions + manifest across the respawn — omitting them
|
|
38
|
-
// would respawn the JSContext with no permissions, so SUBSCRIBE gates and
|
|
39
|
-
// per-call dispatch checks would start rejecting after a background
|
|
40
|
-
// hot-reload.
|
|
41
|
-
const resolved = await miniappLauncher.resolveBundle(packageName)
|
|
42
|
-
if (!resolved) {
|
|
43
|
-
console.warn(`JSRuntime: respawn-bg could not resolve bundle for ${packageName}`)
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
// Force a respawn (kill + spawn) rather than launcher.ensureRunning,
|
|
47
|
-
// which is idempotent and would no-op an already-registered context.
|
|
48
|
-
await router.unregister(packageName)
|
|
49
|
-
const ok = await router.spawnAndRegister(packageName, resolved.bgSource, {
|
|
50
|
-
permissions: resolved.declaredPermissions,
|
|
51
|
-
installedManifest: resolved.installedManifest,
|
|
52
|
-
})
|
|
53
|
-
if (!ok) {
|
|
54
|
-
console.warn(`JSRuntime: respawn-bg failed for ${packageName}`)
|
|
55
|
-
return
|
|
56
|
-
}
|
|
57
|
-
// The respawned JSContext is a fresh MiniappSession with ui.bound false.
|
|
58
|
-
// The mounted WebView won't re-fire glassly.ready() (it's latched), so
|
|
59
|
-
// re-announce it so RPC replies (glassly.request) reach the UI again
|
|
60
|
-
// instead of being dropped while unbound.
|
|
61
|
-
uiRouter.notifyReopen(packageName)
|
|
71
|
+
await respawnBackground(router, uiRouter, packageName)
|
|
62
72
|
} catch (e) {
|
|
63
73
|
console.warn(`JSRuntime: respawn-bg threw for ${packageName}:`, e)
|
|
64
74
|
}
|
|
65
75
|
})
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
/** Packages with a live background JSContext. */
|
|
79
|
+
export function runningMiniappPackages(): string[] {
|
|
80
|
+
return engine?.router.registeredPackages() ?? []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Tear down a miniapp's background JSContext; a no-op when it isn't running. */
|
|
84
|
+
export async function stopMiniapp(packageName: string): Promise<void> {
|
|
85
|
+
await engine?.router.unregister(packageName)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Start (or restart) a miniapp's background so it reads its storage afresh (an
|
|
90
|
+
* app state restore). Resolves false when the engine isn't up or the spawn failed.
|
|
91
|
+
*/
|
|
92
|
+
export async function respawnMiniapp(packageName: string): Promise<boolean> {
|
|
93
|
+
const current = engine
|
|
94
|
+
if (!current) return false
|
|
95
|
+
try {
|
|
96
|
+
return await respawnBackground(current.router, current.uiRouter, packageName)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.warn(`JSRuntime: respawn threw for ${packageName}:`, e)
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
68
103
|
/**
|
|
69
104
|
* Construct (once) and return the JSRuntime engine singletons. Starts the router's
|
|
70
105
|
* message pump on first call. Subsequent calls return the same instances.
|
|
@@ -16,6 +16,7 @@ import CrustModule from "@glassly/crust"
|
|
|
16
16
|
import {resolveMapboxAccessToken, resolveMapboxRenderToken, userMapboxApiKey} from "./MapboxTokenService"
|
|
17
17
|
import {decodePolyline, parseDurationSeconds} from "./navigation/routesApiCodec"
|
|
18
18
|
import {resolveStepRoads} from "./navigation/roadNameResolver"
|
|
19
|
+
import {BgTimer} from "../utils/timers"
|
|
19
20
|
|
|
20
21
|
const LOG_TAG = "NAV_SERVICE"
|
|
21
22
|
|
|
@@ -394,7 +395,7 @@ class NavigationService {
|
|
|
394
395
|
sessionToken: string,
|
|
395
396
|
): Promise<{
|
|
396
397
|
ok: boolean
|
|
397
|
-
suggestions?: Array<{placeId: string; mainText: string; secondaryText: string}>
|
|
398
|
+
suggestions?: Array<{placeId: string; mainText: string; secondaryText: string; distanceMeters?: number}>
|
|
398
399
|
error?: string
|
|
399
400
|
}> {
|
|
400
401
|
return placeAutocompleteViaSearchBox(query, near, sessionToken)
|
|
@@ -506,6 +507,19 @@ export default navigationService
|
|
|
506
507
|
const MAPBOX_DIRECTIONS_BASE = "https://api.mapbox.com/directions/v5/mapbox"
|
|
507
508
|
const MAPBOX_GEOCODE_REVERSE = "https://api.mapbox.com/search/geocode/v6/reverse"
|
|
508
509
|
const MAPBOX_SEARCHBOX_SUGGEST = "https://api.mapbox.com/search/searchbox/v1/suggest"
|
|
510
|
+
|
|
511
|
+
/** Place search sits in front of a voice command; a stalled call must fail over, not hang. */
|
|
512
|
+
const PLACE_SEARCH_TIMEOUT_MS = 6_000
|
|
513
|
+
|
|
514
|
+
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
|
|
515
|
+
const controller = new AbortController()
|
|
516
|
+
const timer = BgTimer.setTimeout(() => controller.abort(), timeoutMs)
|
|
517
|
+
try {
|
|
518
|
+
return await fetch(url, {method: "GET", signal: controller.signal})
|
|
519
|
+
} finally {
|
|
520
|
+
BgTimer.clearTimeout(timer)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
509
523
|
const MAPBOX_SEARCHBOX_RETRIEVE = "https://api.mapbox.com/search/searchbox/v1/retrieve"
|
|
510
524
|
|
|
511
525
|
/**
|
|
@@ -818,7 +832,7 @@ async function placeAutocompleteViaSearchBox(
|
|
|
818
832
|
sessionToken: string,
|
|
819
833
|
): Promise<{
|
|
820
834
|
ok: boolean
|
|
821
|
-
suggestions?: Array<{placeId: string; mainText: string; secondaryText: string}>
|
|
835
|
+
suggestions?: Array<{placeId: string; mainText: string; secondaryText: string; distanceMeters?: number}>
|
|
822
836
|
error?: string
|
|
823
837
|
}> {
|
|
824
838
|
if (!query.trim()) return {ok: true, suggestions: []}
|
|
@@ -837,11 +851,14 @@ async function placeAutocompleteViaSearchBox(
|
|
|
837
851
|
types: "poi,address,place,street",
|
|
838
852
|
limit: "10",
|
|
839
853
|
})
|
|
840
|
-
// Mapbox wants lng,lat for proximity bias.
|
|
841
|
-
if (near)
|
|
854
|
+
// Mapbox wants lng,lat for proximity bias; `origin` adds per-suggestion distance.
|
|
855
|
+
if (near) {
|
|
856
|
+
params.set("proximity", `${near.lng},${near.lat}`)
|
|
857
|
+
params.set("origin", `${near.lng},${near.lat}`)
|
|
858
|
+
}
|
|
842
859
|
|
|
843
860
|
try {
|
|
844
|
-
const res = await
|
|
861
|
+
const res = await fetchWithTimeout(`${MAPBOX_SEARCHBOX_SUGGEST}?${params.toString()}`, PLACE_SEARCH_TIMEOUT_MS)
|
|
845
862
|
if (!res.ok) return {ok: false, error: `Search Box suggest ${res.status}`}
|
|
846
863
|
const json = (await res.json()) as {
|
|
847
864
|
suggestions?: Array<{
|
|
@@ -849,6 +866,7 @@ async function placeAutocompleteViaSearchBox(
|
|
|
849
866
|
name?: string
|
|
850
867
|
place_formatted?: string
|
|
851
868
|
full_address?: string
|
|
869
|
+
distance?: number
|
|
852
870
|
}>
|
|
853
871
|
}
|
|
854
872
|
const suggestions = (json.suggestions ?? [])
|
|
@@ -857,6 +875,7 @@ async function placeAutocompleteViaSearchBox(
|
|
|
857
875
|
placeId: s.mapbox_id,
|
|
858
876
|
mainText: s.name ?? "",
|
|
859
877
|
secondaryText: s.place_formatted ?? s.full_address ?? "",
|
|
878
|
+
...(typeof s.distance === "number" ? {distanceMeters: s.distance} : {}),
|
|
860
879
|
}))
|
|
861
880
|
return {ok: true, suggestions}
|
|
862
881
|
} catch (err) {
|
|
@@ -890,9 +909,10 @@ async function placeDetailsViaSearchBox(
|
|
|
890
909
|
}
|
|
891
910
|
const params = new URLSearchParams({access_token: token, session_token: sessionToken})
|
|
892
911
|
try {
|
|
893
|
-
const res = await
|
|
894
|
-
|
|
895
|
-
|
|
912
|
+
const res = await fetchWithTimeout(
|
|
913
|
+
`${MAPBOX_SEARCHBOX_RETRIEVE}/${encodeURIComponent(placeId)}?${params.toString()}`,
|
|
914
|
+
PLACE_SEARCH_TIMEOUT_MS,
|
|
915
|
+
)
|
|
896
916
|
if (!res.ok) return {ok: false, error: `Search Box retrieve ${res.status}`}
|
|
897
917
|
const json = (await res.json()) as {
|
|
898
918
|
features?: Array<{
|