@glassly/engine 0.3.7 → 0.3.8-dev.1000

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 (212) hide show
  1. package/README.md +4 -0
  2. package/build/engine.d.ts +10 -2
  3. package/build/engine.d.ts.map +1 -1
  4. package/build/engine.js +7 -4
  5. package/build/engine.js.map +1 -1
  6. package/build/facades/ai.d.ts +3 -0
  7. package/build/facades/ai.d.ts.map +1 -1
  8. package/build/facades/ai.js +3 -0
  9. package/build/facades/ai.js.map +1 -1
  10. package/build/facades/backup.d.ts +14 -0
  11. package/build/facades/backup.d.ts.map +1 -0
  12. package/build/facades/backup.js +22 -0
  13. package/build/facades/backup.js.map +1 -0
  14. package/build/facades/glasses.d.ts +4 -4
  15. package/build/facades/glasses.d.ts.map +1 -1
  16. package/build/facades/glasses.js +4 -4
  17. package/build/facades/glasses.js.map +1 -1
  18. package/build/generated/releaseMetadata.js +4 -4
  19. package/build/generated/releaseMetadata.js.map +1 -1
  20. package/build/index.d.ts +2 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js.map +1 -1
  23. package/build/services/AppRegistry.d.ts +2 -1
  24. package/build/services/AppRegistry.d.ts.map +1 -1
  25. package/build/services/AppRegistry.js +38 -8
  26. package/build/services/AppRegistry.js.map +1 -1
  27. package/build/services/AppStateBackup.d.ts +98 -0
  28. package/build/services/AppStateBackup.d.ts.map +1 -0
  29. package/build/services/AppStateBackup.js +224 -0
  30. package/build/services/AppStateBackup.js.map +1 -0
  31. package/build/services/BlobStore.d.ts.map +1 -1
  32. package/build/services/BlobStore.js +5 -12
  33. package/build/services/BlobStore.js.map +1 -1
  34. package/build/services/CloudClientService.d.ts +1 -0
  35. package/build/services/CloudClientService.d.ts.map +1 -1
  36. package/build/services/CloudClientService.js +34 -2
  37. package/build/services/CloudClientService.js.map +1 -1
  38. package/build/services/DisplayDiagnostics.d.ts.map +1 -1
  39. package/build/services/DisplayDiagnostics.js +4 -2
  40. package/build/services/DisplayDiagnostics.js.map +1 -1
  41. package/build/services/DisplayProcessor.d.ts +2 -0
  42. package/build/services/DisplayProcessor.d.ts.map +1 -1
  43. package/build/services/DisplayProcessor.js +15 -1
  44. package/build/services/DisplayProcessor.js.map +1 -1
  45. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  46. package/build/services/GlassesTouchInput.js +4 -2
  47. package/build/services/GlassesTouchInput.js.map +1 -1
  48. package/build/services/JSCrashloopReportService.d.ts.map +1 -1
  49. package/build/services/JSCrashloopReportService.js +5 -0
  50. package/build/services/JSCrashloopReportService.js.map +1 -1
  51. package/build/services/JSRouter.d.ts +11 -3
  52. package/build/services/JSRouter.d.ts.map +1 -1
  53. package/build/services/JSRouter.js +36 -6
  54. package/build/services/JSRouter.js.map +1 -1
  55. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  56. package/build/services/LocalDisplayManager.js +5 -2
  57. package/build/services/LocalDisplayManager.js.map +1 -1
  58. package/build/services/LocalMiniappRuntime.d.ts +13 -0
  59. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  60. package/build/services/LocalMiniappRuntime.js +61 -22
  61. package/build/services/LocalMiniappRuntime.js.map +1 -1
  62. package/build/services/LocalSttFallbackCoordinator.d.ts.map +1 -1
  63. package/build/services/LocalSttFallbackCoordinator.js +2 -0
  64. package/build/services/LocalSttFallbackCoordinator.js.map +1 -1
  65. package/build/services/LocalTranslationService.d.ts +2 -0
  66. package/build/services/LocalTranslationService.d.ts.map +1 -1
  67. package/build/services/LocalTranslationService.js +22 -0
  68. package/build/services/LocalTranslationService.js.map +1 -1
  69. package/build/services/MiniappEngine.d.ts +9 -0
  70. package/build/services/MiniappEngine.d.ts.map +1 -1
  71. package/build/services/MiniappEngine.js +60 -27
  72. package/build/services/MiniappEngine.js.map +1 -1
  73. package/build/services/MiniappLauncher.d.ts +2 -0
  74. package/build/services/MiniappLauncher.d.ts.map +1 -1
  75. package/build/services/MiniappLauncher.js +6 -0
  76. package/build/services/MiniappLauncher.js.map +1 -1
  77. package/build/services/NavigationService.d.ts +1 -0
  78. package/build/services/NavigationService.d.ts.map +1 -1
  79. package/build/services/NavigationService.js +20 -6
  80. package/build/services/NavigationService.js.map +1 -1
  81. package/build/services/OfflineActivity.d.ts +54 -0
  82. package/build/services/OfflineActivity.d.ts.map +1 -0
  83. package/build/services/OfflineActivity.js +86 -0
  84. package/build/services/OfflineActivity.js.map +1 -0
  85. package/build/services/PhoneCalendarService.d.ts.map +1 -1
  86. package/build/services/PhoneCalendarService.js +8 -0
  87. package/build/services/PhoneCalendarService.js.map +1 -1
  88. package/build/services/PhoneNotificationsSync.d.ts.map +1 -1
  89. package/build/services/PhoneNotificationsSync.js +7 -3
  90. package/build/services/PhoneNotificationsSync.js.map +1 -1
  91. package/build/services/STTModelManager.d.ts +1 -0
  92. package/build/services/STTModelManager.d.ts.map +1 -1
  93. package/build/services/STTModelManager.js +6 -0
  94. package/build/services/STTModelManager.js.map +1 -1
  95. package/build/services/SceneRenderer.js +1 -1
  96. package/build/services/SceneRenderer.js.map +1 -1
  97. package/build/services/appStateBackupFile.d.ts +32 -0
  98. package/build/services/appStateBackupFile.d.ts.map +1 -0
  99. package/build/services/appStateBackupFile.js +135 -0
  100. package/build/services/appStateBackupFile.js.map +1 -0
  101. package/build/services/controllers.d.ts.map +1 -1
  102. package/build/services/controllers.js +12 -3
  103. package/build/services/controllers.js.map +1 -1
  104. package/build/services/effectiveCapabilities.js +6 -1
  105. package/build/services/effectiveCapabilities.js.map +1 -1
  106. package/build/services/pages/pageEligibility.d.ts.map +1 -1
  107. package/build/services/pages/pageEligibility.js +8 -1
  108. package/build/services/pages/pageEligibility.js.map +1 -1
  109. package/build/services/settingsBridge.d.ts.map +1 -1
  110. package/build/services/settingsBridge.js +1 -0
  111. package/build/services/settingsBridge.js.map +1 -1
  112. package/build/services/{appleWatch/AppleWatchLink.d.ts → watch/WatchLink.d.ts} +8 -7
  113. package/build/services/watch/WatchLink.d.ts.map +1 -0
  114. package/build/services/{appleWatch/AppleWatchLink.js → watch/WatchLink.js} +14 -15
  115. package/build/services/watch/WatchLink.js.map +1 -0
  116. package/build/stores/apps.d.ts.map +1 -1
  117. package/build/stores/apps.js +22 -10
  118. package/build/stores/apps.js.map +1 -1
  119. package/build/stores/settings.d.ts.map +1 -1
  120. package/build/stores/settings.js +63 -6
  121. package/build/stores/settings.js.map +1 -1
  122. package/build/types/capabilities/even-realities-g1.d.ts.map +1 -1
  123. package/build/types/capabilities/even-realities-g1.js +3 -0
  124. package/build/types/capabilities/even-realities-g1.js.map +1 -1
  125. package/build/types/capabilities/memomind-one.d.ts +3 -0
  126. package/build/types/capabilities/memomind-one.d.ts.map +1 -0
  127. package/build/types/capabilities/memomind-one.js +42 -0
  128. package/build/types/capabilities/memomind-one.js.map +1 -0
  129. package/build/types/capabilities/rayneo-io.d.ts.map +1 -1
  130. package/build/types/capabilities/rayneo-io.js +3 -2
  131. package/build/types/capabilities/rayneo-io.js.map +1 -1
  132. package/build/types/enums.d.ts +7 -2
  133. package/build/types/enums.d.ts.map +1 -1
  134. package/build/types/enums.js +5 -0
  135. package/build/types/enums.js.map +1 -1
  136. package/build/types/hardware.d.ts +3 -1
  137. package/build/types/hardware.d.ts.map +1 -1
  138. package/build/types/hardware.js +3 -1
  139. package/build/types/hardware.js.map +1 -1
  140. package/build/utils/display/index.d.ts +2 -1
  141. package/build/utils/display/index.d.ts.map +1 -1
  142. package/build/utils/display/index.js +3 -2
  143. package/build/utils/display/index.js.map +1 -1
  144. package/build/utils/display/profiles/index.d.ts +2 -1
  145. package/build/utils/display/profiles/index.d.ts.map +1 -1
  146. package/build/utils/display/profiles/index.js +3 -2
  147. package/build/utils/display/profiles/index.js.map +1 -1
  148. package/build/utils/display/profiles/rayneo-io.d.ts +1 -1
  149. package/build/utils/display/profiles/rayneo-io.d.ts.map +1 -1
  150. package/build/utils/display/profiles/rayneo-io.js +1 -1
  151. package/build/utils/display/profiles/rayneo-io.js.map +1 -1
  152. package/build/utils/display/scene/index.d.ts +1 -1
  153. package/build/utils/display/scene/index.d.ts.map +1 -1
  154. package/build/utils/display/scene/index.js +1 -1
  155. package/build/utils/display/scene/index.js.map +1 -1
  156. package/build/utils/display/scene/process.d.ts +6 -0
  157. package/build/utils/display/scene/process.d.ts.map +1 -1
  158. package/build/utils/display/scene/process.js +22 -7
  159. package/build/utils/display/scene/process.js.map +1 -1
  160. package/build/utils/miniappGlobals.d.ts +13 -17
  161. package/build/utils/miniappGlobals.d.ts.map +1 -1
  162. package/build/utils/miniappGlobals.js +24 -56
  163. package/build/utils/miniappGlobals.js.map +1 -1
  164. package/package.json +7 -7
  165. package/src/engine.ts +7 -4
  166. package/src/facades/ai.ts +4 -0
  167. package/src/facades/backup.ts +28 -0
  168. package/src/facades/glasses.ts +5 -5
  169. package/src/generated/releaseMetadata.ts +4 -4
  170. package/src/index.ts +9 -0
  171. package/src/services/AppRegistry.ts +36 -9
  172. package/src/services/AppStateBackup.ts +283 -0
  173. package/src/services/BlobStore.ts +5 -11
  174. package/src/services/CloudClientService.ts +35 -4
  175. package/src/services/DisplayDiagnostics.ts +4 -2
  176. package/src/services/DisplayProcessor.ts +20 -8
  177. package/src/services/GlassesTouchInput.ts +6 -2
  178. package/src/services/JSCrashloopReportService.ts +5 -0
  179. package/src/services/JSRouter.ts +37 -6
  180. package/src/services/LocalDisplayManager.ts +5 -2
  181. package/src/services/LocalMiniappRuntime.ts +61 -22
  182. package/src/services/LocalSttFallbackCoordinator.ts +4 -0
  183. package/src/services/LocalTranslationService.ts +20 -0
  184. package/src/services/MiniappEngine.ts +62 -27
  185. package/src/services/MiniappLauncher.ts +7 -0
  186. package/src/services/NavigationService.ts +28 -8
  187. package/src/services/OfflineActivity.ts +131 -0
  188. package/src/services/PhoneCalendarService.ts +7 -0
  189. package/src/services/PhoneNotificationsSync.ts +8 -3
  190. package/src/services/STTModelManager.ts +6 -0
  191. package/src/services/SceneRenderer.ts +1 -1
  192. package/src/services/appStateBackupFile.ts +171 -0
  193. package/src/services/controllers.ts +13 -3
  194. package/src/services/effectiveCapabilities.ts +6 -1
  195. package/src/services/pages/pageEligibility.ts +9 -1
  196. package/src/services/settingsBridge.ts +1 -0
  197. package/src/services/{appleWatch/AppleWatchLink.ts → watch/WatchLink.ts} +20 -20
  198. package/src/stores/apps.ts +22 -11
  199. package/src/stores/settings.ts +70 -10
  200. package/src/types/capabilities/even-realities-g1.ts +4 -0
  201. package/src/types/capabilities/memomind-one.ts +43 -0
  202. package/src/types/capabilities/rayneo-io.ts +3 -2
  203. package/src/types/enums.ts +5 -0
  204. package/src/types/hardware.ts +14 -1
  205. package/src/utils/display/index.ts +4 -2
  206. package/src/utils/display/profiles/index.ts +9 -2
  207. package/src/utils/display/profiles/rayneo-io.ts +6 -1
  208. package/src/utils/display/scene/index.ts +1 -1
  209. package/src/utils/display/scene/process.ts +22 -7
  210. package/src/utils/miniappGlobals.ts +32 -64
  211. package/build/services/appleWatch/AppleWatchLink.d.ts.map +0 -1
  212. package/build/services/appleWatch/AppleWatchLink.js.map +0 -1
@@ -146,6 +146,9 @@ export class JSRouter {
146
146
  */
147
147
  uiRouter: UIRouter | null = null
148
148
 
149
+ /** Teardowns in progress, so a stop and a racing start or stop see the same one. */
150
+ private readonly unregistering = new Map<string, Promise<void>>()
151
+
149
152
  constructor(
150
153
  private readonly runtime: LocalMiniappRuntime,
151
154
  private readonly crust: JSCrustBinding,
@@ -351,12 +354,26 @@ export class JSRouter {
351
354
  }
352
355
 
353
356
  /**
354
- * Tear down a JSContext and unregister from the runtime. Synchronous
355
- * teardown — the spec drops the 50ms WILL_DISCONNECT grace window
356
- * because there's no transport handshake to flush in the JSC world.
357
+ * Tear down a JSContext and unregister from the runtime. The session's
358
+ * `onBeforeDisconnect` handlers get a short grace window first, so a
359
+ * miniapp can clear what it drew and end its own work while its messages
360
+ * still reach the host. Concurrent calls share one teardown.
357
361
  */
358
- async unregister(packageName: string): Promise<void> {
359
- if (!this.registered.has(packageName)) return
362
+ unregister(packageName: string): Promise<void> {
363
+ if (!this.registered.has(packageName)) return Promise.resolve()
364
+ const inFlight = this.unregistering.get(packageName)
365
+ if (inFlight) return inFlight
366
+ const teardown = this.teardown(packageName).finally(() => this.unregistering.delete(packageName))
367
+ this.unregistering.set(packageName, teardown)
368
+ return teardown
369
+ }
370
+
371
+ /** Resolves once an in-flight {@link unregister} of this package has finished. */
372
+ async whenUnregistered(packageName: string): Promise<void> {
373
+ await this.unregistering.get(packageName)
374
+ }
375
+
376
+ private async teardown(packageName: string): Promise<void> {
360
377
  // Cancel any pending crash-respawn before tearing down — we don't
361
378
  // want a stale timer to re-spawn a miniapp the user just disabled.
362
379
  const pending = this.respawnTimers.get(packageName)
@@ -364,6 +381,7 @@ export class JSRouter {
364
381
  BgTimer.clearTimeout(pending)
365
382
  this.respawnTimers.delete(packageName)
366
383
  }
384
+ await this.runtime.notifyWillDisconnect(packageName, "stopped")
367
385
  this.spawnCache.delete(packageName)
368
386
  this.crashController?.onKill(packageName)
369
387
  // User-initiated close/disable: end any active nav trip so the glasses
@@ -382,6 +400,14 @@ export class JSRouter {
382
400
  }
383
401
 
384
402
  /** List packages the router has registered. */
403
+ /** The user opened a running miniapp again; the source rides the next respawn too. */
404
+ notifyLaunch(packageName: string, launchSource: MiniappLaunchSource): void {
405
+ if (!this.registered.has(packageName)) return
406
+ const cached = this.spawnCache.get(packageName)
407
+ if (cached) cached.launchSource = launchSource
408
+ this.runtime.sendLaunchChange(packageName, launchSource)
409
+ }
410
+
385
411
  registeredPackages(): string[] {
386
412
  return Array.from(this.registered)
387
413
  }
@@ -483,13 +509,18 @@ export class JSRouter {
483
509
  if (iface === "__error") {
484
510
  const payload = this.tryParseArgs(msg.argsJson)
485
511
  this.logger.error(`[${packageName}] ${method}`, payload)
512
+ // Crashes reach the `glassly dev` terminal too, not only console calls.
513
+ const summary = this.summarizeReason(payload, method)
514
+ const stack = (payload as {stack?: unknown} | null)?.stack
515
+ const errorArgs = typeof stack === "string" && stack ? [summary, stack] : [summary]
516
+ devServerBridge.forwardLog(packageName, "error", errorArgs, Date.now(), "background")
486
517
  if (this.crashController) {
487
518
  // Only treat "exception" + "unhandledRejection" + "uncaught" as
488
519
  // crash signals. `console.error` calls also flow through the
489
520
  // log path — those don't end the JSContext.
490
521
  const isCrash = method === "exception" || method === "unhandledRejection" || method === "uncaught"
491
522
  if (isCrash) {
492
- this.handleCrash(packageName, this.summarizeReason(payload, method))
523
+ this.handleCrash(packageName, summary)
493
524
  }
494
525
  }
495
526
  return
@@ -627,6 +627,9 @@ class LocalDisplayManager {
627
627
  this.currentDisplay &&
628
628
  this.currentDisplay.packageName === this.backgroundLock.packageName
629
629
  if (bgHoldsAndDisplays) {
630
+ // The retained scene is now older than the saved frame; forget it so
631
+ // the restore paints this frame rather than replaying the stale one.
632
+ sceneRenderer.clearApp(packageName)
630
633
  resolve?.({status: "blocked", reason: "a background app holds the display"})
631
634
  return
632
635
  }
@@ -974,8 +977,8 @@ class LocalDisplayManager {
974
977
  }, delay)
975
978
  }
976
979
  } else {
977
- // Retained state gone (app stopped since) — re-render the saved
978
- // elements as a fresh scene instead.
980
+ // Retained state gone (app stopped, or its newer frame was blocked) —
981
+ // re-render the saved elements as a fresh scene instead.
979
982
  this.sendNow(saved.packageName, {view: "main", scene: saved.scene, durationMs: remaining})
980
983
  }
981
984
  return
@@ -57,6 +57,7 @@ import type {AiTier, AiTierProvider, SavedLlmKeys} from "./AiTierResolution"
57
57
  import {decideLlmRoute, localLlmReason, type LocalLlmReason} from "./LlmRouting"
58
58
  import localLlmModelManager from "./LocalLlmModelManager"
59
59
  import localLlmProvider from "./LocalLlmProvider"
60
+ import offlineActivity from "./OfflineActivity"
60
61
  import {HttpError} from "@glassly/cloud-client"
61
62
  import type {
62
63
  QuotaStatusPayload,
@@ -304,6 +305,20 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | nul
304
305
  })
305
306
  }
306
307
 
308
+ /** Bounds the cold-GPS wait for a one-shot poll; on a stall, any older fix beats an error. */
309
+ const FRESH_LOCATION_TIMEOUT_MS = 8_000
310
+
311
+ async function freshLocationOrStale(): Promise<Location.LocationObject> {
312
+ const fresh = await withTimeout(
313
+ Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Low}),
314
+ FRESH_LOCATION_TIMEOUT_MS,
315
+ )
316
+ if (fresh) return fresh
317
+ const stale = await Location.getLastKnownPositionAsync()
318
+ if (stale) return stale
319
+ throw new Error("No location fix available")
320
+ }
321
+
307
322
  const LOG_TAG = "LOCAL_MINIAPP"
308
323
  const DIAGNOSTIC_MAX_LIST_ITEMS = 100
309
324
  const DIAGNOSTIC_MAX_STRING_LENGTH = 512
@@ -326,6 +341,8 @@ const SYSTEM_MINIAPP_PACKAGES = new Set([
326
341
  "com.glassly.miniappdev",
327
342
  ])
328
343
  const PING_INTERVAL_MS = 5_000
344
+ /** How long a stopping miniapp's `beforeDisconnect` handlers get before teardown. */
345
+ const WILL_DISCONNECT_GRACE_MS = 100
329
346
  // Min gap between "transcription limit reached" notices on the glasses. A
330
347
  // debounce, not a cooldown: the cloud re-announces the block per refused
331
348
  // subscribe, and miniapps retry in bursts — this only coalesces those bursts
@@ -1235,21 +1252,26 @@ class LocalMiniappRuntime {
1235
1252
  * undeliverable anyway.
1236
1253
  */
1237
1254
  public async gracefullyUnregisterApp(packageName: string, reason = "unregistering"): Promise<void> {
1238
- const app = this.connectedApps.get(packageName)
1239
- if (!app) return
1240
-
1241
- console.log(`${LOG_TAG}: gracefullyUnregisterApp(${packageName}) — sending WILL_DISCONNECT`)
1242
- this.sendToMiniapp(packageName, {
1243
- type: MiniappResponseType.WILL_DISCONNECT,
1244
- reason,
1245
- })
1246
- // 50 ms: imperceptible to the user, plenty of time for the SDK's
1247
- // synchronous `beforeDisconnect` handlers to flush a final
1248
- // sendOneShot through the still-open transport.
1249
- await new Promise<void>((resolve) => BgTimer.setTimeout(resolve, 50))
1255
+ if (!this.connectedApps.has(packageName)) return
1256
+ await this.notifyWillDisconnect(packageName, reason)
1250
1257
  this.unregisterApp(packageName)
1251
1258
  }
1252
1259
 
1260
+ /**
1261
+ * Send `WILL_DISCONNECT` and wait out the grace window, so the session's
1262
+ * `beforeDisconnect` handlers run (Navigation clears its HUD and ends its
1263
+ * trip) and their last messages land before teardown. No-op unless the
1264
+ * app finished its handshake.
1265
+ */
1266
+ public async notifyWillDisconnect(packageName: string, reason: string): Promise<void> {
1267
+ if (!this.connectedApps.has(packageName) || !this.handshookApps.has(packageName)) return
1268
+ console.log(`${LOG_TAG}: ${packageName} — sending WILL_DISCONNECT (${reason})`)
1269
+ this.sendToMiniapp(packageName, {type: MiniappResponseType.WILL_DISCONNECT, reason})
1270
+ // The message and the replies it triggers each cross a JSContext hop;
1271
+ // short enough not to slow an app switch noticeably.
1272
+ await new Promise<void>((resolve) => BgTimer.setTimeout(resolve, WILL_DISCONNECT_GRACE_MS))
1273
+ }
1274
+
1253
1275
  public unregisterApp(packageName: string, opts?: {endNavigation?: boolean}): void {
1254
1276
  console.log(`${LOG_TAG}: unregisterApp(${packageName})`)
1255
1277
  const releasedMicGateOverride = micStateCoordinator.clearMiniappGateOverrides(packageName)
@@ -1659,11 +1681,11 @@ class LocalMiniappRuntime {
1659
1681
  this.handleOpenUrl(packageName, payload)
1660
1682
  break
1661
1683
  case MiniappRequestType.DISPLAY_DIAGNOSTICS: {
1662
- // The records are parsed out of the G2PERF lines, which the SDK only logs while g2_perf_logs is on.
1684
+ // The records are parsed out of the G2PERF lines, which the SDK only logs while perf_logs is on.
1663
1685
  const settings = useSettingsStore.getState()
1664
1686
  const enabled =
1665
1687
  settings.getSetting(SETTINGS.debug_console.key) === true &&
1666
- settings.getSetting(SETTINGS.g2_perf_logs.key) === true
1688
+ settings.getSetting(SETTINGS.perf_logs.key) === true
1667
1689
  this.sendResult(packageName, requestId, true, {
1668
1690
  enabled,
1669
1691
  generatedAt: Date.now(),
@@ -3450,7 +3472,7 @@ class LocalMiniappRuntime {
3450
3472
  const locationStartedAt = Date.now()
3451
3473
  const cached = await Location.getLastKnownPositionAsync({maxAge: 60_000})
3452
3474
  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}))
3475
+ const location = cached ?? (await freshLocationOrStale())
3454
3476
  console.log(`${LOG_TAG}: location poll resolved elapsed=${Date.now() - locationStartedAt}ms`)
3455
3477
 
3456
3478
  this.sendResult(packageName, requestId, true, {
@@ -4322,6 +4344,7 @@ class LocalMiniappRuntime {
4322
4344
  return
4323
4345
  }
4324
4346
 
4347
+ const endActivity = offlineActivity.beginLlm(reason, localLlmModelManager.model(modelId).label)
4325
4348
  try {
4326
4349
  // webSearch is a vendor server-side grant; the phone has none.
4327
4350
  const {webSearch: _webSearch, ...localRequest} = request
@@ -4346,6 +4369,8 @@ class LocalMiniappRuntime {
4346
4369
  undefined,
4347
4370
  primaryError ?? {code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE, message},
4348
4371
  )
4372
+ } finally {
4373
+ endActivity()
4349
4374
  }
4350
4375
  }
4351
4376
 
@@ -6071,13 +6096,16 @@ class LocalMiniappRuntime {
6071
6096
  private async startInteropApp(packageName: string): Promise<boolean> {
6072
6097
  const app = this.interopApps().find((a) => a.packageName === packageName)
6073
6098
  if (!app) return false
6074
- // 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})
6076
- if (app.local) {
6077
- await miniappLauncher.ensureConnected(packageName)
6078
- return true
6079
- }
6080
- return useAppStatusStore.getState().start(app, {skipNavigation: true})
6099
+ // session.miniapps.start comes from the glasses (the dashboard's Apps menu,
6100
+ // an Assist voice command), unlike a headless action wake, which carries no source.
6101
+ // The full start, as a phone open runs it: the standard peer stops so this
6102
+ // app owns the display, and an app already running hears the new launch.
6103
+ const launchSource = "glasses" as const
6104
+ const started = await useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
6105
+ if (!started) return false
6106
+ // The caller may message the app next; start() resolves at spawn.
6107
+ if (app.local) await miniappLauncher.ensureConnected(packageName, undefined, {launchSource})
6108
+ return true
6081
6109
  }
6082
6110
 
6083
6111
  private stopInteropApp(packageName: string): Promise<void> {
@@ -6851,6 +6879,17 @@ class LocalMiniappRuntime {
6851
6879
  this.sendToMiniapp(packageName, {type: MiniappResponseType.LINK, params})
6852
6880
  }
6853
6881
 
6882
+ /**
6883
+ * Tell a running miniapp the user opened it again and from where. The
6884
+ * CONNECT_ACK only carries the source of the spawn, which is stale for a
6885
+ * background app that keeps running between opens.
6886
+ */
6887
+ public sendLaunchChange(packageName: string, launchSource: MiniappLaunchSource): void {
6888
+ const existing = this.connectedApps.get(packageName)
6889
+ if (existing) existing.launchSource = launchSource
6890
+ this.sendToMiniapp(packageName, {type: MiniappResponseType.LAUNCH_CHANGE, launchSource})
6891
+ }
6892
+
6854
6893
  /**
6855
6894
  * Send a VISIBILITY_CHANGE push to a miniapp.
6856
6895
  */
@@ -2,6 +2,7 @@ import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
2
2
  import {ISLAND_SETTINGS_KEYS} from "../runtime/config"
3
3
  import {useSettingsStore} from "../stores/settings"
4
4
  import {cloudClientService} from "./CloudClientService"
5
+ import offlineActivity from "./OfflineActivity"
5
6
  import sttModelManager from "./STTModelManager"
6
7
 
7
8
  /**
@@ -223,6 +224,9 @@ class LocalSttFallbackCoordinator {
223
224
 
224
225
  private publishReason(): void {
225
226
  const reason = this.localActive ? this.currentReason() : ""
227
+ offlineActivity.setTranscription(
228
+ this.localActive ? {reason: this.currentReason(), model: sttModelManager.currentModelLabel()} : null,
229
+ )
226
230
  const store = useSettingsStore.getState()
227
231
  if (store.getSetting(ISLAND_SETTINGS_KEYS.localSttFallbackReason) !== reason) {
228
232
  store.setSetting(ISLAND_SETTINGS_KEYS.localSttFallbackReason, reason)
@@ -1,3 +1,4 @@
1
+ import offlineActivity, {type ServedTranslationPair} from "./OfflineActivity"
1
2
  import {offlineTranslationManager} from "./OfflineTranslationManager"
2
3
 
3
4
  /** One concrete-target `translation:<source>:<target>` stream and who listens to it. */
@@ -67,6 +68,7 @@ export class LocalTranslationService {
67
68
 
68
69
  attach(host: Host): void {
69
70
  this.host = host
71
+ offlineActivity.setTranslationSource(() => this.servedPairs())
70
72
  }
71
73
 
72
74
  setSubscriptions(subscriptions: LocalTranslationSubscription[]): void {
@@ -75,6 +77,24 @@ export class LocalTranslationService {
75
77
  for (const key of this.lanes.keys()) {
76
78
  if (!live.has(key.substring(key.indexOf(">") + 1))) this.lanes.delete(key)
77
79
  }
80
+ offlineActivity.refresh()
81
+ }
82
+
83
+ servedPairs(): ServedTranslationPair[] {
84
+ if (!this.host || !offlineTranslationManager.isSupported()) return []
85
+ const pairs = new Map<string, ServedTranslationPair>()
86
+ let primaryUp: boolean | null = null
87
+ for (const sub of this.subscriptions) {
88
+ if (baseLanguage(sub.source) === baseLanguage(sub.target)) continue
89
+ if (!sub.forceLocal) {
90
+ primaryUp ??= this.host.primaryUp()
91
+ if (primaryUp || !sub.cloud) continue
92
+ }
93
+ const key = `${sub.source}>${sub.target}`
94
+ const existing = pairs.get(key)
95
+ pairs.set(key, {source: sub.source, target: sub.target, forceLocal: sub.forceLocal || !!existing?.forceLocal})
96
+ }
97
+ return [...pairs.values()]
78
98
  }
79
99
 
80
100
  onLocalTranscript(transcript: LocalTranscript, sourceLanguage: string): 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.
@@ -91,6 +91,11 @@ class MiniappLauncher {
91
91
  }
92
92
 
93
93
  /** True iff the package's background JS context is currently spawned. */
94
+ /** Tell an already-running miniapp the user opened it again and from where. */
95
+ notifyLaunch(packageName: string, launchSource: MiniappLaunchSource): void {
96
+ this.deps?.router.notifyLaunch(packageName, launchSource)
97
+ }
98
+
94
99
  isRunning(packageName: string): boolean {
95
100
  return this.deps?.router.registeredPackages().includes(packageName) ?? false
96
101
  }
@@ -228,6 +233,8 @@ class MiniappLauncher {
228
233
  */
229
234
  async ensureRunning(packageName: string, hints?: LaunchHints): Promise<LaunchResult> {
230
235
  const router = this.requireRouter()
236
+ // A context still tearing down from a stop is not one to reuse.
237
+ await router.whenUnregistered(packageName)
231
238
 
232
239
  // Already spawned: best-effort resolve for the UI entry, never throw —
233
240
  // a headless caller that doesn't need UI gets {null, null} fast even if
@@ -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<{
@@ -0,0 +1,131 @@
1
+ /**
2
+ * What the phone is running on-device right now (transcription, translation,
3
+ * LLM) and why, for the home offline card. Producers push in; it imports
4
+ * nothing at runtime so their test stubs stay unaffected.
5
+ */
6
+ import type {LocalLlmReason} from "./LlmRouting"
7
+ import type {LocalSttFallbackReason} from "./LocalSttFallbackCoordinator"
8
+
9
+ export type OfflineActivityReason = LocalSttFallbackReason | LocalLlmReason
10
+
11
+ export interface OfflineTranscriptionActivity {
12
+ reason: LocalSttFallbackReason
13
+ model: string
14
+ }
15
+
16
+ export interface OfflineTranslationPair {
17
+ source: string
18
+ target: string
19
+ }
20
+
21
+ export interface OfflineTranslationActivity {
22
+ reason: OfflineActivityReason
23
+ pairs: OfflineTranslationPair[]
24
+ }
25
+
26
+ export interface OfflineLlmActivity {
27
+ reason: LocalLlmReason
28
+ model: string
29
+ requests: number
30
+ }
31
+
32
+ export interface OfflineActivity {
33
+ transcription: OfflineTranscriptionActivity | null
34
+ translation: OfflineTranslationActivity | null
35
+ llm: OfflineLlmActivity | null
36
+ }
37
+
38
+ export interface ServedTranslationPair extends OfflineTranslationPair {
39
+ forceLocal: boolean
40
+ }
41
+
42
+ const EMPTY: OfflineActivity = {transcription: null, translation: null, llm: null}
43
+
44
+ class OfflineActivityTracker {
45
+ private transcription: OfflineTranscriptionActivity | null = null
46
+ private llmRuns: Array<{reason: LocalLlmReason; model: string}> = []
47
+ private translationSource: (() => ServedTranslationPair[]) | null = null
48
+ private snapshot: OfflineActivity = EMPTY
49
+ private snapshotKey = JSON.stringify(EMPTY)
50
+ private listeners = new Set<(activity: OfflineActivity) => void>()
51
+
52
+ current(): OfflineActivity {
53
+ return this.snapshot
54
+ }
55
+
56
+ subscribe(listener: (activity: OfflineActivity) => void): () => void {
57
+ this.listeners.add(listener)
58
+ return () => {
59
+ this.listeners.delete(listener)
60
+ }
61
+ }
62
+
63
+ setTranscription(transcription: OfflineTranscriptionActivity | null): void {
64
+ this.transcription = transcription
65
+ this.refresh()
66
+ }
67
+
68
+ beginLlm(reason: LocalLlmReason, model: string): () => void {
69
+ const run = {reason, model}
70
+ this.llmRuns.push(run)
71
+ this.refresh()
72
+ let ended = false
73
+ return () => {
74
+ if (ended) return
75
+ ended = true
76
+ this.llmRuns = this.llmRuns.filter((r) => r !== run)
77
+ this.refresh()
78
+ }
79
+ }
80
+
81
+ setTranslationSource(source: (() => ServedTranslationPair[]) | null): void {
82
+ this.translationSource = source
83
+ this.refresh()
84
+ }
85
+
86
+ refresh(): void {
87
+ const next: OfflineActivity = {
88
+ transcription: this.transcription,
89
+ translation: this.translation(),
90
+ llm: this.llm(),
91
+ }
92
+ const key = JSON.stringify(next)
93
+ if (key === this.snapshotKey) return
94
+ this.snapshotKey = key
95
+ this.snapshot = next
96
+ for (const listener of this.listeners) {
97
+ try {
98
+ listener(next)
99
+ } catch (error) {
100
+ console.warn("OfflineActivity: listener failed:", error)
101
+ }
102
+ }
103
+ }
104
+
105
+ reset(): void {
106
+ this.transcription = null
107
+ this.llmRuns = []
108
+ this.translationSource = null
109
+ this.refresh()
110
+ }
111
+
112
+ private translation(): OfflineTranslationActivity | null {
113
+ if (!this.transcription || !this.translationSource) return null
114
+ const pairs = this.translationSource()
115
+ if (pairs.length === 0) return null
116
+ return {
117
+ reason: pairs.some((p) => p.forceLocal) ? "force_local" : this.transcription.reason,
118
+ pairs: pairs.map(({source, target}) => ({source, target})),
119
+ }
120
+ }
121
+
122
+ private llm(): OfflineLlmActivity | null {
123
+ const latest = this.llmRuns[this.llmRuns.length - 1]
124
+ if (!latest) return null
125
+ return {reason: latest.reason, model: latest.model, requests: this.llmRuns.length}
126
+ }
127
+ }
128
+
129
+ export {OfflineActivityTracker}
130
+ const offlineActivity = new OfflineActivityTracker()
131
+ export default offlineActivity