@glassly/engine 0.3.7 → 0.3.8-beta.984

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 (187) hide show
  1. package/README.md +4 -0
  2. package/build/engine.d.ts +8 -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/backup.d.ts +14 -0
  7. package/build/facades/backup.d.ts.map +1 -0
  8. package/build/facades/backup.js +22 -0
  9. package/build/facades/backup.js.map +1 -0
  10. package/build/facades/glasses.d.ts +4 -4
  11. package/build/facades/glasses.d.ts.map +1 -1
  12. package/build/facades/glasses.js +4 -4
  13. package/build/facades/glasses.js.map +1 -1
  14. package/build/generated/releaseMetadata.js +4 -4
  15. package/build/generated/releaseMetadata.js.map +1 -1
  16. package/build/index.d.ts +1 -0
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js.map +1 -1
  19. package/build/services/AppRegistry.d.ts +2 -1
  20. package/build/services/AppRegistry.d.ts.map +1 -1
  21. package/build/services/AppRegistry.js +38 -8
  22. package/build/services/AppRegistry.js.map +1 -1
  23. package/build/services/AppStateBackup.d.ts +98 -0
  24. package/build/services/AppStateBackup.d.ts.map +1 -0
  25. package/build/services/AppStateBackup.js +224 -0
  26. package/build/services/AppStateBackup.js.map +1 -0
  27. package/build/services/BlobStore.d.ts.map +1 -1
  28. package/build/services/BlobStore.js +5 -12
  29. package/build/services/BlobStore.js.map +1 -1
  30. package/build/services/CloudClientService.d.ts +1 -0
  31. package/build/services/CloudClientService.d.ts.map +1 -1
  32. package/build/services/CloudClientService.js +34 -2
  33. package/build/services/CloudClientService.js.map +1 -1
  34. package/build/services/DisplayDiagnostics.d.ts.map +1 -1
  35. package/build/services/DisplayDiagnostics.js +4 -2
  36. package/build/services/DisplayDiagnostics.js.map +1 -1
  37. package/build/services/DisplayProcessor.d.ts +2 -0
  38. package/build/services/DisplayProcessor.d.ts.map +1 -1
  39. package/build/services/DisplayProcessor.js +15 -1
  40. package/build/services/DisplayProcessor.js.map +1 -1
  41. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  42. package/build/services/GlassesTouchInput.js +4 -2
  43. package/build/services/GlassesTouchInput.js.map +1 -1
  44. package/build/services/JSCrashloopReportService.d.ts.map +1 -1
  45. package/build/services/JSCrashloopReportService.js +5 -0
  46. package/build/services/JSCrashloopReportService.js.map +1 -1
  47. package/build/services/JSRouter.d.ts +11 -3
  48. package/build/services/JSRouter.d.ts.map +1 -1
  49. package/build/services/JSRouter.js +36 -6
  50. package/build/services/JSRouter.js.map +1 -1
  51. package/build/services/LocalDisplayManager.d.ts +2 -0
  52. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  53. package/build/services/LocalDisplayManager.js +4 -0
  54. package/build/services/LocalDisplayManager.js.map +1 -1
  55. package/build/services/LocalMiniappRuntime.d.ts +19 -0
  56. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  57. package/build/services/LocalMiniappRuntime.js +79 -22
  58. package/build/services/LocalMiniappRuntime.js.map +1 -1
  59. package/build/services/MiniappEngine.d.ts +9 -0
  60. package/build/services/MiniappEngine.d.ts.map +1 -1
  61. package/build/services/MiniappEngine.js +60 -27
  62. package/build/services/MiniappEngine.js.map +1 -1
  63. package/build/services/MiniappLauncher.d.ts +2 -0
  64. package/build/services/MiniappLauncher.d.ts.map +1 -1
  65. package/build/services/MiniappLauncher.js +6 -0
  66. package/build/services/MiniappLauncher.js.map +1 -1
  67. package/build/services/NavigationService.d.ts +1 -0
  68. package/build/services/NavigationService.d.ts.map +1 -1
  69. package/build/services/NavigationService.js +20 -6
  70. package/build/services/NavigationService.js.map +1 -1
  71. package/build/services/PhoneCalendarService.d.ts.map +1 -1
  72. package/build/services/PhoneCalendarService.js +8 -0
  73. package/build/services/PhoneCalendarService.js.map +1 -1
  74. package/build/services/PhoneNotificationsSync.d.ts.map +1 -1
  75. package/build/services/PhoneNotificationsSync.js +7 -3
  76. package/build/services/PhoneNotificationsSync.js.map +1 -1
  77. package/build/services/SceneRenderer.d.ts +8 -0
  78. package/build/services/SceneRenderer.d.ts.map +1 -1
  79. package/build/services/SceneRenderer.js +29 -1
  80. package/build/services/SceneRenderer.js.map +1 -1
  81. package/build/services/appStateBackupFile.d.ts +32 -0
  82. package/build/services/appStateBackupFile.d.ts.map +1 -0
  83. package/build/services/appStateBackupFile.js +135 -0
  84. package/build/services/appStateBackupFile.js.map +1 -0
  85. package/build/services/controllers.d.ts.map +1 -1
  86. package/build/services/controllers.js +11 -3
  87. package/build/services/controllers.js.map +1 -1
  88. package/build/services/effectiveCapabilities.js +3 -0
  89. package/build/services/effectiveCapabilities.js.map +1 -1
  90. package/build/services/pages/pageEligibility.d.ts.map +1 -1
  91. package/build/services/pages/pageEligibility.js +8 -1
  92. package/build/services/pages/pageEligibility.js.map +1 -1
  93. package/build/services/settingsBridge.d.ts.map +1 -1
  94. package/build/services/settingsBridge.js +1 -0
  95. package/build/services/settingsBridge.js.map +1 -1
  96. package/build/services/{appleWatch/AppleWatchLink.d.ts → watch/WatchLink.d.ts} +8 -7
  97. package/build/services/watch/WatchLink.d.ts.map +1 -0
  98. package/build/services/{appleWatch/AppleWatchLink.js → watch/WatchLink.js} +14 -15
  99. package/build/services/watch/WatchLink.js.map +1 -0
  100. package/build/stores/apps.d.ts.map +1 -1
  101. package/build/stores/apps.js +22 -10
  102. package/build/stores/apps.js.map +1 -1
  103. package/build/stores/settings.d.ts.map +1 -1
  104. package/build/stores/settings.js +56 -6
  105. package/build/stores/settings.js.map +1 -1
  106. package/build/types/capabilities/even-realities-g1.d.ts.map +1 -1
  107. package/build/types/capabilities/even-realities-g1.js +3 -0
  108. package/build/types/capabilities/even-realities-g1.js.map +1 -1
  109. package/build/types/capabilities/memomind-one.d.ts +3 -0
  110. package/build/types/capabilities/memomind-one.d.ts.map +1 -0
  111. package/build/types/capabilities/memomind-one.js +41 -0
  112. package/build/types/capabilities/memomind-one.js.map +1 -0
  113. package/build/types/enums.d.ts +6 -2
  114. package/build/types/enums.d.ts.map +1 -1
  115. package/build/types/enums.js +4 -0
  116. package/build/types/enums.js.map +1 -1
  117. package/build/types/hardware.d.ts +3 -1
  118. package/build/types/hardware.d.ts.map +1 -1
  119. package/build/types/hardware.js +3 -1
  120. package/build/types/hardware.js.map +1 -1
  121. package/build/utils/display/index.d.ts +2 -1
  122. package/build/utils/display/index.d.ts.map +1 -1
  123. package/build/utils/display/index.js +3 -2
  124. package/build/utils/display/index.js.map +1 -1
  125. package/build/utils/display/profiles/index.d.ts +2 -1
  126. package/build/utils/display/profiles/index.d.ts.map +1 -1
  127. package/build/utils/display/profiles/index.js +3 -2
  128. package/build/utils/display/profiles/index.js.map +1 -1
  129. package/build/utils/display/profiles/rayneo-io.d.ts +1 -1
  130. package/build/utils/display/profiles/rayneo-io.d.ts.map +1 -1
  131. package/build/utils/display/profiles/rayneo-io.js +1 -1
  132. package/build/utils/display/profiles/rayneo-io.js.map +1 -1
  133. package/build/utils/display/scene/index.d.ts +1 -1
  134. package/build/utils/display/scene/index.d.ts.map +1 -1
  135. package/build/utils/display/scene/index.js +1 -1
  136. package/build/utils/display/scene/index.js.map +1 -1
  137. package/build/utils/display/scene/process.d.ts +6 -0
  138. package/build/utils/display/scene/process.d.ts.map +1 -1
  139. package/build/utils/display/scene/process.js +22 -7
  140. package/build/utils/display/scene/process.js.map +1 -1
  141. package/build/utils/miniappGlobals.d.ts +13 -17
  142. package/build/utils/miniappGlobals.d.ts.map +1 -1
  143. package/build/utils/miniappGlobals.js +24 -56
  144. package/build/utils/miniappGlobals.js.map +1 -1
  145. package/package.json +7 -7
  146. package/src/engine.ts +7 -4
  147. package/src/facades/backup.ts +28 -0
  148. package/src/facades/glasses.ts +5 -5
  149. package/src/generated/releaseMetadata.ts +4 -4
  150. package/src/index.ts +1 -0
  151. package/src/services/AppRegistry.ts +36 -9
  152. package/src/services/AppStateBackup.ts +283 -0
  153. package/src/services/BlobStore.ts +5 -11
  154. package/src/services/CloudClientService.ts +35 -4
  155. package/src/services/DisplayDiagnostics.ts +4 -2
  156. package/src/services/DisplayProcessor.ts +20 -8
  157. package/src/services/GlassesTouchInput.ts +6 -2
  158. package/src/services/JSCrashloopReportService.ts +5 -0
  159. package/src/services/JSRouter.ts +37 -6
  160. package/src/services/LocalDisplayManager.ts +5 -0
  161. package/src/services/LocalMiniappRuntime.ts +81 -22
  162. package/src/services/MiniappEngine.ts +62 -27
  163. package/src/services/MiniappLauncher.ts +7 -0
  164. package/src/services/NavigationService.ts +28 -8
  165. package/src/services/PhoneCalendarService.ts +7 -0
  166. package/src/services/PhoneNotificationsSync.ts +8 -3
  167. package/src/services/SceneRenderer.ts +29 -1
  168. package/src/services/appStateBackupFile.ts +171 -0
  169. package/src/services/controllers.ts +12 -3
  170. package/src/services/effectiveCapabilities.ts +3 -0
  171. package/src/services/pages/pageEligibility.ts +9 -1
  172. package/src/services/settingsBridge.ts +1 -0
  173. package/src/services/{appleWatch/AppleWatchLink.ts → watch/WatchLink.ts} +20 -20
  174. package/src/stores/apps.ts +22 -11
  175. package/src/stores/settings.ts +63 -10
  176. package/src/types/capabilities/even-realities-g1.ts +4 -0
  177. package/src/types/capabilities/memomind-one.ts +42 -0
  178. package/src/types/enums.ts +4 -0
  179. package/src/types/hardware.ts +14 -1
  180. package/src/utils/display/index.ts +4 -2
  181. package/src/utils/display/profiles/index.ts +9 -2
  182. package/src/utils/display/profiles/rayneo-io.ts +6 -1
  183. package/src/utils/display/scene/index.ts +1 -1
  184. package/src/utils/display/scene/process.ts +22 -7
  185. package/src/utils/miniappGlobals.ts +32 -64
  186. package/build/services/appleWatch/AppleWatchLink.d.ts.map +0 -1
  187. 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
@@ -489,6 +489,11 @@ class LocalDisplayManager {
489
489
  }
490
490
  }
491
491
 
492
+ /** A frame for native to keep warm, never shown (SceneRenderer.prefetchScene). */
493
+ public prefetch(packageName: string, view: "main" | "dashboard", elements: SceneElementInput[]): boolean {
494
+ return sceneRenderer.prefetchScene(packageName, view, elements)
495
+ }
496
+
492
497
  /**
493
498
  * The overlay slot only; bypasses arbitration and main ownership state.
494
499
  * Frames from an owner other than the slot's are dropped. Returns the scene
@@ -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
@@ -326,6 +340,8 @@ const SYSTEM_MINIAPP_PACKAGES = new Set([
326
340
  "com.glassly.miniappdev",
327
341
  ])
328
342
  const PING_INTERVAL_MS = 5_000
343
+ /** How long a stopping miniapp's `beforeDisconnect` handlers get before teardown. */
344
+ const WILL_DISCONNECT_GRACE_MS = 100
329
345
  // Min gap between "transcription limit reached" notices on the glasses. A
330
346
  // debounce, not a cooldown: the cloud re-announces the block per refused
331
347
  // subscribe, and miniapps retry in bursts — this only coalesces those bursts
@@ -1235,21 +1251,26 @@ class LocalMiniappRuntime {
1235
1251
  * undeliverable anyway.
1236
1252
  */
1237
1253
  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))
1254
+ if (!this.connectedApps.has(packageName)) return
1255
+ await this.notifyWillDisconnect(packageName, reason)
1250
1256
  this.unregisterApp(packageName)
1251
1257
  }
1252
1258
 
1259
+ /**
1260
+ * Send `WILL_DISCONNECT` and wait out the grace window, so the session's
1261
+ * `beforeDisconnect` handlers run (Navigation clears its HUD and ends its
1262
+ * trip) and their last messages land before teardown. No-op unless the
1263
+ * app finished its handshake.
1264
+ */
1265
+ public async notifyWillDisconnect(packageName: string, reason: string): Promise<void> {
1266
+ if (!this.connectedApps.has(packageName) || !this.handshookApps.has(packageName)) return
1267
+ console.log(`${LOG_TAG}: ${packageName} — sending WILL_DISCONNECT (${reason})`)
1268
+ this.sendToMiniapp(packageName, {type: MiniappResponseType.WILL_DISCONNECT, reason})
1269
+ // The message and the replies it triggers each cross a JSContext hop;
1270
+ // short enough not to slow an app switch noticeably.
1271
+ await new Promise<void>((resolve) => BgTimer.setTimeout(resolve, WILL_DISCONNECT_GRACE_MS))
1272
+ }
1273
+
1253
1274
  public unregisterApp(packageName: string, opts?: {endNavigation?: boolean}): void {
1254
1275
  console.log(`${LOG_TAG}: unregisterApp(${packageName})`)
1255
1276
  const releasedMicGateOverride = micStateCoordinator.clearMiniappGateOverrides(packageName)
@@ -1474,6 +1495,9 @@ class LocalMiniappRuntime {
1474
1495
  case MiniappRequestType.SHOW_VIEW:
1475
1496
  this.handleShowView(packageName, payload, requestId)
1476
1497
  break
1498
+ case MiniappRequestType.PREFETCH:
1499
+ this.handlePrefetch(packageName, payload, requestId)
1500
+ break
1477
1501
  case MiniappRequestType.SETTINGS_GET:
1478
1502
  this.handleSettingsGet(packageName, payload, requestId)
1479
1503
  break
@@ -1659,11 +1683,11 @@ class LocalMiniappRuntime {
1659
1683
  this.handleOpenUrl(packageName, payload)
1660
1684
  break
1661
1685
  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.
1686
+ // The records are parsed out of the G2PERF lines, which the SDK only logs while perf_logs is on.
1663
1687
  const settings = useSettingsStore.getState()
1664
1688
  const enabled =
1665
1689
  settings.getSetting(SETTINGS.debug_console.key) === true &&
1666
- settings.getSetting(SETTINGS.g2_perf_logs.key) === true
1690
+ settings.getSetting(SETTINGS.perf_logs.key) === true
1667
1691
  this.sendResult(packageName, requestId, true, {
1668
1692
  enabled,
1669
1693
  generatedAt: Date.now(),
@@ -2630,6 +2654,27 @@ class LocalMiniappRuntime {
2630
2654
  )
2631
2655
  }
2632
2656
 
2657
+ /**
2658
+ * display.prefetch: the active dashboard hands over the frame its view would
2659
+ * show if opened now. Native keeps its fonts and images on the glasses while
2660
+ * nothing is on screen; nothing is displayed or retained here.
2661
+ */
2662
+ private handlePrefetch(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
2663
+ if (payload.view !== "dashboard" || !Array.isArray(payload.elements)) {
2664
+ this.sendResult(packageName, requestId, false, undefined, {
2665
+ code: MiniappErrorCode.INVALID_ARGUMENT,
2666
+ message: 'prefetch takes the "dashboard" view and an elements array',
2667
+ })
2668
+ return
2669
+ }
2670
+ if (!this.connectedApps.has(packageName) || !isDashboardApp(packageName) || !isOverlayApp(packageName)) {
2671
+ this.sendResult(packageName, requestId, true, {status: "unsupported", reason: "not the active dashboard"})
2672
+ return
2673
+ }
2674
+ const sent = localDisplayManager.prefetch(packageName, "dashboard", payload.elements as NonNullable<DisplayPayload["scene"]>)
2675
+ this.sendResult(packageName, requestId, true, sent ? {status: "queued"} : {status: "unsupported", reason: "no scene display"})
2676
+ }
2677
+
2633
2678
  /**
2634
2679
  * Reject the request with PERMISSION_NOT_DECLARED unless the caller's
2635
2680
  * manifest declares `permission`. System apps pass without declaring it.
@@ -3450,7 +3495,7 @@ class LocalMiniappRuntime {
3450
3495
  const locationStartedAt = Date.now()
3451
3496
  const cached = await Location.getLastKnownPositionAsync({maxAge: 60_000})
3452
3497
  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}))
3498
+ const location = cached ?? (await freshLocationOrStale())
3454
3499
  console.log(`${LOG_TAG}: location poll resolved elapsed=${Date.now() - locationStartedAt}ms`)
3455
3500
 
3456
3501
  this.sendResult(packageName, requestId, true, {
@@ -6071,13 +6116,16 @@ class LocalMiniappRuntime {
6071
6116
  private async startInteropApp(packageName: string): Promise<boolean> {
6072
6117
  const app = this.interopApps().find((a) => a.packageName === packageName)
6073
6118
  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})
6119
+ // session.miniapps.start comes from the glasses (the dashboard's Apps menu,
6120
+ // an Assist voice command), unlike a headless action wake, which carries no source.
6121
+ // The full start, as a phone open runs it: the standard peer stops so this
6122
+ // app owns the display, and an app already running hears the new launch.
6123
+ const launchSource = "glasses" as const
6124
+ const started = await useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
6125
+ if (!started) return false
6126
+ // The caller may message the app next; start() resolves at spawn.
6127
+ if (app.local) await miniappLauncher.ensureConnected(packageName, undefined, {launchSource})
6128
+ return true
6081
6129
  }
6082
6130
 
6083
6131
  private stopInteropApp(packageName: string): Promise<void> {
@@ -6851,6 +6899,17 @@ class LocalMiniappRuntime {
6851
6899
  this.sendToMiniapp(packageName, {type: MiniappResponseType.LINK, params})
6852
6900
  }
6853
6901
 
6902
+ /**
6903
+ * Tell a running miniapp the user opened it again and from where. The
6904
+ * CONNECT_ACK only carries the source of the spawn, which is stale for a
6905
+ * background app that keeps running between opens.
6906
+ */
6907
+ public sendLaunchChange(packageName: string, launchSource: MiniappLaunchSource): void {
6908
+ const existing = this.connectedApps.get(packageName)
6909
+ if (existing) existing.launchSource = launchSource
6910
+ this.sendToMiniapp(packageName, {type: MiniappResponseType.LAUNCH_CHANGE, launchSource})
6911
+ }
6912
+
6854
6913
  /**
6855
6914
  * Send a VISIBILITY_CHANGE push to a miniapp.
6856
6915
  */
@@ -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<{
@@ -49,9 +49,16 @@ export async function listPhoneCalendarEvents(request: CalendarListRequest): Pro
49
49
  if (calendars.length === 0) return {events: [], truncated: false}
50
50
 
51
51
  const rawEvents = await Calendar.listEvents(calendars, startsAt, endsAt)
52
+ const seen = new Set<string>()
52
53
  const events = rawEvents
53
54
  .map(normalizeCalendarEvent)
54
55
  .filter((event) => calendarEventOverlapsWindow(event, startsAt, endsAt))
56
+ .filter((event) => {
57
+ const key = `${event.title}\n${event.startsAt}\n${event.endsAt}\n${event.allDay}`
58
+ if (seen.has(key)) return false
59
+ seen.add(key)
60
+ return true
61
+ })
55
62
  .sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt) || Date.parse(a.endsAt) - Date.parse(b.endsAt))
56
63
 
57
64
  return {
@@ -91,6 +91,11 @@ export function nativeNotificationsSupported(): boolean {
91
91
  return getModelCapabilities(model).hasNativeNotifications === true
92
92
  }
93
93
 
94
+ function notificationRelaySupported(): boolean {
95
+ const model = useGlassesStore.getState().deviceModel as DeviceTypes
96
+ return Platform.OS === "ios" && getModelCapabilities(model).hasNotificationRelay === true
97
+ }
98
+
94
99
  /** Popup durations the firmware accepts, or an empty list when it takes any. */
95
100
  export function nativeNotificationPopupSeconds(): number[] {
96
101
  const model = useGlassesStore.getState().deviceModel as DeviceTypes
@@ -141,10 +146,10 @@ export function currentPresentationOwner(hostActive: boolean): PresentationOwner
141
146
  */
142
147
  export function captureAuthorization(): "granted" | "denied" | "unknown" | "not_needed" {
143
148
  if (Platform.OS !== "ios") return "not_needed"
144
- if (!nativeNotificationsSupported()) return "not_needed"
149
+ if (!nativeNotificationsSupported() && !notificationRelaySupported()) return "not_needed"
145
150
  const glasses = useGlassesStore.getState()
146
151
  if (!isGlassesConnected(glasses.connection)) return "unknown"
147
- return glasses.ancsAuthorized ? "granted" : "denied"
152
+ return glasses.ancsAuthorized ? "granted" : notificationRelaySupported() ? "unknown" : "denied"
148
153
  }
149
154
 
150
155
  /**
@@ -178,7 +183,7 @@ function pushListenerConfig(): void {
178
183
  }
179
184
 
180
185
  function pushGlassesConfig(): void {
181
- if (!nativeNotificationsSupported()) return
186
+ if (!nativeNotificationsSupported() && !notificationRelaySupported()) return
182
187
  if (!isGlassesConnected(useGlassesStore.getState().connection)) return
183
188
  void Promise.resolve(BluetoothSdk.setGlassesNotificationConfig(desiredGlassesNotificationConfig())).catch(
184
189
  (err: unknown) =>
@@ -250,7 +250,7 @@ class SceneRenderer {
250
250
  return {kind: "legacy", layout, degraded, dropped}
251
251
  }
252
252
 
253
- const processed = processScene(elements, caps, displayProcessor.getProfile())
253
+ const processed = processScene(elements, caps, displayProcessor.getSceneProfile())
254
254
  // A stale baseline means the glasses were cleared since the last commit —
255
255
  // diff from empty so everything repaints (the retained frame stays valid
256
256
  // as the replay source only).
@@ -283,6 +283,34 @@ class SceneRenderer {
283
283
  }
284
284
  }
285
285
 
286
+ /**
287
+ * Hand native the frame `elements` would make for (app, view) without
288
+ * showing it or touching the retained scene: drivers with an on-glass asset
289
+ * cache upload its fonts and images while the panel is idle. Every element
290
+ * is "created" and the frame carries seq 0 (no presentation report). Empty
291
+ * elements drop it. False when there is nothing to send it to.
292
+ */
293
+ public prefetchScene(appId: string, view: "main" | "dashboard", elements: SceneElementInput[]): boolean {
294
+ const caps = this.currentCapabilities()
295
+ if (!caps?.canPosition) return false
296
+ const processed = processScene(resolveAnchors(elements, caps.width, caps.height), caps, displayProcessor.getSceneProfile())
297
+ let synthetic = 0
298
+ const {elements: created} = diffScene([], processed.elements, () => `~prefetch${synthetic++}`)
299
+ const frame: SceneFrame = {
300
+ appId,
301
+ view,
302
+ sceneEpoch: 0,
303
+ seq: 0,
304
+ elements: created,
305
+ removed: [],
306
+ canvas: {width: caps.width, height: caps.height},
307
+ }
308
+ void Promise.resolve(BluetoothSdk.displayEvent({view, scene: frame, prefetch: true})).catch((err) => {
309
+ console.error(`${LOG_TAG}: native prefetch failed:`, err)
310
+ })
311
+ return true
312
+ }
313
+
286
314
  /**
287
315
  * Re-emit an app's retained scene (reconnect / arbitration restore). Replay
288
316
  * is always create-based — the device rebuilds from scratch (spec §4).