@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.
Files changed (54) hide show
  1. package/build/engine.d.ts +6 -0
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +3 -0
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/backup.d.ts +14 -0
  6. package/build/facades/backup.d.ts.map +1 -0
  7. package/build/facades/backup.js +22 -0
  8. package/build/facades/backup.js.map +1 -0
  9. package/build/generated/releaseMetadata.js +3 -3
  10. package/build/generated/releaseMetadata.js.map +1 -1
  11. package/build/index.d.ts +1 -0
  12. package/build/index.d.ts.map +1 -1
  13. package/build/index.js.map +1 -1
  14. package/build/services/AppRegistry.d.ts +2 -1
  15. package/build/services/AppRegistry.d.ts.map +1 -1
  16. package/build/services/AppRegistry.js +9 -2
  17. package/build/services/AppRegistry.js.map +1 -1
  18. package/build/services/AppStateBackup.d.ts +98 -0
  19. package/build/services/AppStateBackup.d.ts.map +1 -0
  20. package/build/services/AppStateBackup.js +224 -0
  21. package/build/services/AppStateBackup.js.map +1 -0
  22. package/build/services/CloudClientService.d.ts +1 -0
  23. package/build/services/CloudClientService.d.ts.map +1 -1
  24. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  25. package/build/services/LocalMiniappRuntime.js +18 -4
  26. package/build/services/LocalMiniappRuntime.js.map +1 -1
  27. package/build/services/MiniappEngine.d.ts +9 -0
  28. package/build/services/MiniappEngine.d.ts.map +1 -1
  29. package/build/services/MiniappEngine.js +60 -27
  30. package/build/services/MiniappEngine.js.map +1 -1
  31. package/build/services/NavigationService.d.ts +1 -0
  32. package/build/services/NavigationService.d.ts.map +1 -1
  33. package/build/services/NavigationService.js +20 -6
  34. package/build/services/NavigationService.js.map +1 -1
  35. package/build/services/appStateBackupFile.d.ts +32 -0
  36. package/build/services/appStateBackupFile.d.ts.map +1 -0
  37. package/build/services/appStateBackupFile.js +135 -0
  38. package/build/services/appStateBackupFile.js.map +1 -0
  39. package/build/utils/miniappGlobals.d.ts +13 -17
  40. package/build/utils/miniappGlobals.d.ts.map +1 -1
  41. package/build/utils/miniappGlobals.js +24 -56
  42. package/build/utils/miniappGlobals.js.map +1 -1
  43. package/package.json +7 -7
  44. package/src/engine.ts +3 -0
  45. package/src/facades/backup.ts +28 -0
  46. package/src/generated/releaseMetadata.ts +3 -3
  47. package/src/index.ts +1 -0
  48. package/src/services/AppRegistry.ts +8 -3
  49. package/src/services/AppStateBackup.ts +283 -0
  50. package/src/services/LocalMiniappRuntime.ts +21 -4
  51. package/src/services/MiniappEngine.ts +62 -27
  52. package/src/services/NavigationService.ts +28 -8
  53. package/src/services/appStateBackupFile.ts +171 -0
  54. 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 Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Low}))
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
- // Re-resolve the freshly built bundle (dev: HTTP off the dev server;
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) params.set("proximity", `${near.lng},${near.lat}`)
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 fetch(`${MAPBOX_SEARCHBOX_SUGGEST}?${params.toString()}`, {method: "GET"})
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 fetch(`${MAPBOX_SEARCHBOX_RETRIEVE}/${encodeURIComponent(placeId)}?${params.toString()}`, {
894
- method: "GET",
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<{