@glassly/engine 0.3.7 → 0.3.8-dev.977

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 (176) 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/DisplayProcessor.d.ts +2 -0
  35. package/build/services/DisplayProcessor.d.ts.map +1 -1
  36. package/build/services/DisplayProcessor.js +15 -1
  37. package/build/services/DisplayProcessor.js.map +1 -1
  38. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  39. package/build/services/GlassesTouchInput.js +4 -2
  40. package/build/services/GlassesTouchInput.js.map +1 -1
  41. package/build/services/JSCrashloopReportService.d.ts.map +1 -1
  42. package/build/services/JSCrashloopReportService.js +5 -0
  43. package/build/services/JSCrashloopReportService.js.map +1 -1
  44. package/build/services/JSRouter.d.ts +11 -3
  45. package/build/services/JSRouter.d.ts.map +1 -1
  46. package/build/services/JSRouter.js +36 -6
  47. package/build/services/JSRouter.js.map +1 -1
  48. package/build/services/LocalMiniappRuntime.d.ts +13 -0
  49. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  50. package/build/services/LocalMiniappRuntime.js +54 -20
  51. package/build/services/LocalMiniappRuntime.js.map +1 -1
  52. package/build/services/MiniappEngine.d.ts +9 -0
  53. package/build/services/MiniappEngine.d.ts.map +1 -1
  54. package/build/services/MiniappEngine.js +60 -27
  55. package/build/services/MiniappEngine.js.map +1 -1
  56. package/build/services/MiniappLauncher.d.ts +2 -0
  57. package/build/services/MiniappLauncher.d.ts.map +1 -1
  58. package/build/services/MiniappLauncher.js +6 -0
  59. package/build/services/MiniappLauncher.js.map +1 -1
  60. package/build/services/NavigationService.d.ts +1 -0
  61. package/build/services/NavigationService.d.ts.map +1 -1
  62. package/build/services/NavigationService.js +20 -6
  63. package/build/services/NavigationService.js.map +1 -1
  64. package/build/services/PhoneCalendarService.d.ts.map +1 -1
  65. package/build/services/PhoneCalendarService.js +8 -0
  66. package/build/services/PhoneCalendarService.js.map +1 -1
  67. package/build/services/PhoneNotificationsSync.d.ts.map +1 -1
  68. package/build/services/PhoneNotificationsSync.js +7 -3
  69. package/build/services/PhoneNotificationsSync.js.map +1 -1
  70. package/build/services/SceneRenderer.js +1 -1
  71. package/build/services/SceneRenderer.js.map +1 -1
  72. package/build/services/appStateBackupFile.d.ts +32 -0
  73. package/build/services/appStateBackupFile.d.ts.map +1 -0
  74. package/build/services/appStateBackupFile.js +135 -0
  75. package/build/services/appStateBackupFile.js.map +1 -0
  76. package/build/services/controllers.d.ts.map +1 -1
  77. package/build/services/controllers.js +11 -3
  78. package/build/services/controllers.js.map +1 -1
  79. package/build/services/effectiveCapabilities.js +3 -0
  80. package/build/services/effectiveCapabilities.js.map +1 -1
  81. package/build/services/pages/pageEligibility.d.ts.map +1 -1
  82. package/build/services/pages/pageEligibility.js +8 -1
  83. package/build/services/pages/pageEligibility.js.map +1 -1
  84. package/build/services/settingsBridge.d.ts.map +1 -1
  85. package/build/services/settingsBridge.js +1 -0
  86. package/build/services/settingsBridge.js.map +1 -1
  87. package/build/services/{appleWatch/AppleWatchLink.d.ts → watch/WatchLink.d.ts} +8 -7
  88. package/build/services/watch/WatchLink.d.ts.map +1 -0
  89. package/build/services/{appleWatch/AppleWatchLink.js → watch/WatchLink.js} +14 -15
  90. package/build/services/watch/WatchLink.js.map +1 -0
  91. package/build/stores/apps.d.ts.map +1 -1
  92. package/build/stores/apps.js +14 -2
  93. package/build/stores/apps.js.map +1 -1
  94. package/build/stores/settings.d.ts.map +1 -1
  95. package/build/stores/settings.js +53 -3
  96. package/build/stores/settings.js.map +1 -1
  97. package/build/types/capabilities/even-realities-g1.d.ts.map +1 -1
  98. package/build/types/capabilities/even-realities-g1.js +3 -0
  99. package/build/types/capabilities/even-realities-g1.js.map +1 -1
  100. package/build/types/capabilities/memomind-one.d.ts +3 -0
  101. package/build/types/capabilities/memomind-one.d.ts.map +1 -0
  102. package/build/types/capabilities/memomind-one.js +41 -0
  103. package/build/types/capabilities/memomind-one.js.map +1 -0
  104. package/build/types/enums.d.ts +6 -2
  105. package/build/types/enums.d.ts.map +1 -1
  106. package/build/types/enums.js +4 -0
  107. package/build/types/enums.js.map +1 -1
  108. package/build/types/hardware.d.ts +3 -1
  109. package/build/types/hardware.d.ts.map +1 -1
  110. package/build/types/hardware.js +3 -1
  111. package/build/types/hardware.js.map +1 -1
  112. package/build/utils/display/index.d.ts +2 -1
  113. package/build/utils/display/index.d.ts.map +1 -1
  114. package/build/utils/display/index.js +3 -2
  115. package/build/utils/display/index.js.map +1 -1
  116. package/build/utils/display/profiles/index.d.ts +2 -1
  117. package/build/utils/display/profiles/index.d.ts.map +1 -1
  118. package/build/utils/display/profiles/index.js +3 -2
  119. package/build/utils/display/profiles/index.js.map +1 -1
  120. package/build/utils/display/profiles/rayneo-io.d.ts +1 -1
  121. package/build/utils/display/profiles/rayneo-io.d.ts.map +1 -1
  122. package/build/utils/display/profiles/rayneo-io.js +1 -1
  123. package/build/utils/display/profiles/rayneo-io.js.map +1 -1
  124. package/build/utils/display/scene/index.d.ts +1 -1
  125. package/build/utils/display/scene/index.d.ts.map +1 -1
  126. package/build/utils/display/scene/index.js +1 -1
  127. package/build/utils/display/scene/index.js.map +1 -1
  128. package/build/utils/display/scene/process.d.ts +6 -0
  129. package/build/utils/display/scene/process.d.ts.map +1 -1
  130. package/build/utils/display/scene/process.js +22 -7
  131. package/build/utils/display/scene/process.js.map +1 -1
  132. package/build/utils/miniappGlobals.d.ts +13 -17
  133. package/build/utils/miniappGlobals.d.ts.map +1 -1
  134. package/build/utils/miniappGlobals.js +24 -56
  135. package/build/utils/miniappGlobals.js.map +1 -1
  136. package/package.json +7 -7
  137. package/src/engine.ts +7 -4
  138. package/src/facades/backup.ts +28 -0
  139. package/src/facades/glasses.ts +5 -5
  140. package/src/generated/releaseMetadata.ts +4 -4
  141. package/src/index.ts +1 -0
  142. package/src/services/AppRegistry.ts +36 -9
  143. package/src/services/AppStateBackup.ts +283 -0
  144. package/src/services/BlobStore.ts +5 -11
  145. package/src/services/CloudClientService.ts +35 -4
  146. package/src/services/DisplayProcessor.ts +20 -8
  147. package/src/services/GlassesTouchInput.ts +6 -2
  148. package/src/services/JSCrashloopReportService.ts +5 -0
  149. package/src/services/JSRouter.ts +37 -6
  150. package/src/services/LocalMiniappRuntime.ts +55 -20
  151. package/src/services/MiniappEngine.ts +62 -27
  152. package/src/services/MiniappLauncher.ts +7 -0
  153. package/src/services/NavigationService.ts +28 -8
  154. package/src/services/PhoneCalendarService.ts +7 -0
  155. package/src/services/PhoneNotificationsSync.ts +8 -3
  156. package/src/services/SceneRenderer.ts +1 -1
  157. package/src/services/appStateBackupFile.ts +171 -0
  158. package/src/services/controllers.ts +12 -3
  159. package/src/services/effectiveCapabilities.ts +3 -0
  160. package/src/services/pages/pageEligibility.ts +9 -1
  161. package/src/services/settingsBridge.ts +1 -0
  162. package/src/services/{appleWatch/AppleWatchLink.ts → watch/WatchLink.ts} +20 -20
  163. package/src/stores/apps.ts +13 -2
  164. package/src/stores/settings.ts +60 -7
  165. package/src/types/capabilities/even-realities-g1.ts +4 -0
  166. package/src/types/capabilities/memomind-one.ts +42 -0
  167. package/src/types/enums.ts +4 -0
  168. package/src/types/hardware.ts +14 -1
  169. package/src/utils/display/index.ts +4 -2
  170. package/src/utils/display/profiles/index.ts +9 -2
  171. package/src/utils/display/profiles/rayneo-io.ts +6 -1
  172. package/src/utils/display/scene/index.ts +1 -1
  173. package/src/utils/display/scene/process.ts +22 -7
  174. package/src/utils/miniappGlobals.ts +32 -64
  175. package/build/services/appleWatch/AppleWatchLink.d.ts.map +0 -1
  176. 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
@@ -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)
@@ -3450,7 +3471,7 @@ class LocalMiniappRuntime {
3450
3471
  const locationStartedAt = Date.now()
3451
3472
  const cached = await Location.getLastKnownPositionAsync({maxAge: 60_000})
3452
3473
  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}))
3474
+ const location = cached ?? (await freshLocationOrStale())
3454
3475
  console.log(`${LOG_TAG}: location poll resolved elapsed=${Date.now() - locationStartedAt}ms`)
3455
3476
 
3456
3477
  this.sendResult(packageName, requestId, true, {
@@ -6071,13 +6092,16 @@ class LocalMiniappRuntime {
6071
6092
  private async startInteropApp(packageName: string): Promise<boolean> {
6072
6093
  const app = this.interopApps().find((a) => a.packageName === packageName)
6073
6094
  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})
6095
+ // session.miniapps.start comes from the glasses (the dashboard's Apps menu,
6096
+ // an Assist voice command), unlike a headless action wake, which carries no source.
6097
+ // The full start, as a phone open runs it: the standard peer stops so this
6098
+ // app owns the display, and an app already running hears the new launch.
6099
+ const launchSource = "glasses" as const
6100
+ const started = await useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
6101
+ if (!started) return false
6102
+ // The caller may message the app next; start() resolves at spawn.
6103
+ if (app.local) await miniappLauncher.ensureConnected(packageName, undefined, {launchSource})
6104
+ return true
6081
6105
  }
6082
6106
 
6083
6107
  private stopInteropApp(packageName: string): Promise<void> {
@@ -6851,6 +6875,17 @@ class LocalMiniappRuntime {
6851
6875
  this.sendToMiniapp(packageName, {type: MiniappResponseType.LINK, params})
6852
6876
  }
6853
6877
 
6878
+ /**
6879
+ * Tell a running miniapp the user opened it again and from where. The
6880
+ * CONNECT_ACK only carries the source of the spawn, which is stale for a
6881
+ * background app that keeps running between opens.
6882
+ */
6883
+ public sendLaunchChange(packageName: string, launchSource: MiniappLaunchSource): void {
6884
+ const existing = this.connectedApps.get(packageName)
6885
+ if (existing) existing.launchSource = launchSource
6886
+ this.sendToMiniapp(packageName, {type: MiniappResponseType.LAUNCH_CHANGE, launchSource})
6887
+ }
6888
+
6854
6889
  /**
6855
6890
  * Send a VISIBILITY_CHANGE push to a miniapp.
6856
6891
  */
@@ -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).
@@ -0,0 +1,171 @@
1
+ /**
2
+ * App state backup files: the share-sheet export and file-picker restore
3
+ * behind Settings > Profile > Backup. What goes in the file is decided in
4
+ * AppStateBackup.ts; this side reads and writes the live stores.
5
+ */
6
+
7
+ import type {MemoryImportResult} from "@glassly/miniapp"
8
+ import {File, Paths} from "expo-file-system"
9
+ import {Platform} from "react-native"
10
+ import Share from "react-native-share"
11
+
12
+ import {SETTINGS, useSettingsStore} from "../stores/settings"
13
+ import {useAppStatusStore} from "../stores/apps"
14
+ import {storage} from "../utils/storage/storage"
15
+ import appRegistry, {saveAlwaysOnOverride, saveWidgetExpandChoice} from "./AppRegistry"
16
+ import {
17
+ AppStateBackupError,
18
+ collectAppStateBackup,
19
+ miniappPrefs,
20
+ miniappsWithStorage,
21
+ parseAppStateBackup,
22
+ restorableSettings,
23
+ restoreMiniappStorage,
24
+ type AppStateBackupBackend,
25
+ } from "./AppStateBackup"
26
+ import {cloudClientService} from "./CloudClientService"
27
+ import {memoryStore} from "./memoryStoreInstance"
28
+ import {respawnMiniapp, runningMiniappPackages, stopMiniapp} from "./MiniappEngine"
29
+
30
+ export interface AppStateExportResult {
31
+ success: boolean
32
+ cancelled?: boolean
33
+ }
34
+
35
+ export interface AppStateRestoreResult {
36
+ /** Settings written. */
37
+ settings: number
38
+ /** Miniapps whose storage was restored. */
39
+ miniapps: number
40
+ memory: MemoryImportResult | null
41
+ /** Installed when the backup was made but not on this phone now. */
42
+ missing: string[]
43
+ }
44
+
45
+ export interface AppStateRestoreOptions {
46
+ /**
47
+ * Install what the backup had installed and this phone lacks. Runs before
48
+ * the restored miniapps start again, so the dashboard comes back with every
49
+ * widget provider its saved layout names: it drops unknown widgets from the
50
+ * layout the next time it saves it.
51
+ */
52
+ reinstall?: (packageNames: string[]) => Promise<void>
53
+ }
54
+
55
+ const PICK_MIME_TYPES =
56
+ Platform.OS === "android" ? ["application/json", "application/octet-stream"] : ["application/json"]
57
+
58
+ const backend: AppStateBackupBackend = {
59
+ get(key) {
60
+ const result = storage.load<unknown>(key)
61
+ return result.is_ok() ? result.value : null
62
+ },
63
+ set(key, value) {
64
+ const result = storage.save(key, value)
65
+ if (result.is_error()) throw result.error
66
+ },
67
+ keys: () => storage.getAllKeys(),
68
+ }
69
+
70
+ export async function exportAppStateFile(): Promise<AppStateExportResult> {
71
+ const userId = await cloudClientService.resolveUserId()
72
+ const installed: Array<{packageName: string; version: string; source?: string}> = []
73
+ for (const app of await appRegistry.getInstalledMiniapps()) {
74
+ const version = await appRegistry.getActiveVersion(app.packageName)
75
+ if (!version) continue
76
+ installed.push({
77
+ packageName: app.packageName,
78
+ version,
79
+ source: appRegistry.getReleaseIdentity(app.packageName, version)?.source,
80
+ })
81
+ }
82
+ const backup = collectAppStateBackup({
83
+ backend,
84
+ userId,
85
+ settings: Object.values(SETTINGS),
86
+ installed,
87
+ memory: await memoryStore.exportBackup(),
88
+ now: Date.now(),
89
+ })
90
+
91
+ const filename = `glassly-backup-${localDate(backup.exportedAt)}.json`
92
+ const file = new File(Paths.cache, filename)
93
+ file.writeSync(JSON.stringify(backup))
94
+ try {
95
+ await Share.open({url: file.uri, type: "application/json", filename, title: "Glassly backup"})
96
+ return {success: true}
97
+ } catch (error: any) {
98
+ // react-native-share rejects when the user dismisses the sheet.
99
+ if (error?.message?.includes("User did not share")) return {success: false, cancelled: true}
100
+ throw error
101
+ }
102
+ }
103
+
104
+ /** Resolves null when the user cancels the picker. */
105
+ export async function importAppStateFile(opts: AppStateRestoreOptions = {}): Promise<AppStateRestoreResult | null> {
106
+ const picked = await File.pickFileAsync({mimeTypes: PICK_MIME_TYPES})
107
+ if (picked.canceled) return null
108
+ let data: unknown
109
+ try {
110
+ data = JSON.parse(await picked.result.text())
111
+ } catch {
112
+ throw new AppStateBackupError("Not a Glassly app state backup")
113
+ }
114
+ const backup = parseAppStateBackup(data)
115
+ const userId = await cloudClientService.resolveUserId()
116
+
117
+ // First, so a memory section this build rejects fails the restore before anything is written.
118
+ const memory = backup.memory ? await memoryStore.importBackup(backup.memory) : null
119
+
120
+ const {plain, indexed} = restorableSettings(backup, Object.values(SETTINGS))
121
+ const written = await useSettingsStore.getState().setManyLocally(plain)
122
+ if (written.is_error()) throw written.error
123
+ // setManyLocally only takes registry keys; `key:index` values go to storage and the store directly.
124
+ for (const [key, value] of Object.entries(indexed)) backend.set(key, value)
125
+ if (Object.keys(indexed).length > 0) {
126
+ useSettingsStore.setState((state) => ({settings: {...state.settings, ...indexed}}))
127
+ }
128
+
129
+ // A running miniapp holds its old values in memory and could write them back over the
130
+ // restore, so the ones getting storage stop now and start again at the end.
131
+ const running = new Set(runningMiniappPackages())
132
+ const restarting = miniappsWithStorage(backup).filter((packageName) => running.has(packageName))
133
+ for (const packageName of restarting) await stopMiniapp(packageName)
134
+
135
+ try {
136
+ const restored = restoreMiniappStorage(backend, userId, backup)
137
+
138
+ const apps = useAppStatusStore.getState()
139
+ for (const [packageName, record] of Object.entries(backup.miniapps)) {
140
+ const prefs = miniappPrefs(record)
141
+ if (prefs.hidden !== undefined) apps.setHiddenStatus(packageName, prefs.hidden)
142
+ if (prefs.alwaysOn !== undefined) saveAlwaysOnOverride(packageName, prefs.alwaysOn)
143
+ for (const [widgetId, expand] of Object.entries(prefs.widgetExpand)) {
144
+ saveWidgetExpandChoice(packageName, widgetId, expand)
145
+ }
146
+ }
147
+ appRegistry.markRefreshNeeded()
148
+ await apps.refresh()
149
+
150
+ const installedNow = new Set((await appRegistry.getInstalledMiniapps()).map((app) => app.packageName))
151
+ const missing = Object.entries(backup.miniapps)
152
+ .filter(([packageName, record]) => record.installed && !installedNow.has(packageName))
153
+ .map(([packageName]) => packageName)
154
+ if (missing.length > 0) await opts.reinstall?.(missing)
155
+
156
+ return {
157
+ settings: Object.keys(plain).length + Object.keys(indexed).length,
158
+ miniapps: restored.packages.length,
159
+ memory,
160
+ missing,
161
+ }
162
+ } finally {
163
+ for (const packageName of restarting) await respawnMiniapp(packageName)
164
+ }
165
+ }
166
+
167
+ function localDate(ms: number): string {
168
+ const d = new Date(ms)
169
+ const pad = (n: number) => String(n).padStart(2, "0")
170
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
171
+ }