@glassly/engine 0.3.1-dev.496 → 0.3.1-dev.525

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 (125) hide show
  1. package/build/engine.d.ts +9 -2
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +5 -0
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/dev.js +1 -1
  6. package/build/facades/dev.js.map +1 -1
  7. package/build/facades/miniapps.d.ts +5 -5
  8. package/build/facades/miniapps.d.ts.map +1 -1
  9. package/build/facades/miniapps.js +5 -6
  10. package/build/facades/miniapps.js.map +1 -1
  11. package/build/facades/speech.d.ts +10 -0
  12. package/build/facades/speech.d.ts.map +1 -1
  13. package/build/facades/speech.js +10 -0
  14. package/build/facades/speech.js.map +1 -1
  15. package/build/generated/releaseMetadata.js +3 -3
  16. package/build/generated/releaseMetadata.js.map +1 -1
  17. package/build/index.d.ts +1 -1
  18. package/build/index.d.ts.map +1 -1
  19. package/build/index.js +1 -1
  20. package/build/index.js.map +1 -1
  21. package/build/services/AppRegistry.d.ts +1 -7
  22. package/build/services/AppRegistry.d.ts.map +1 -1
  23. package/build/services/AppRegistry.js +15 -26
  24. package/build/services/AppRegistry.js.map +1 -1
  25. package/build/services/DevServerBridge.d.ts +1 -1
  26. package/build/services/DevServerBridge.js +2 -2
  27. package/build/services/DevServerBridge.js.map +1 -1
  28. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  29. package/build/services/DeviceEventRouter.js +2 -0
  30. package/build/services/DeviceEventRouter.js.map +1 -1
  31. package/build/services/JSRouter.js +1 -1
  32. package/build/services/JSRouter.js.map +1 -1
  33. package/build/services/LocalMiniappRuntime.d.ts +21 -6
  34. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  35. package/build/services/LocalMiniappRuntime.js +130 -48
  36. package/build/services/LocalMiniappRuntime.js.map +1 -1
  37. package/build/services/LocalTranslationService.d.ts +51 -0
  38. package/build/services/LocalTranslationService.d.ts.map +1 -0
  39. package/build/services/LocalTranslationService.js +141 -0
  40. package/build/services/LocalTranslationService.js.map +1 -0
  41. package/build/services/MiniappLauncher.d.ts +17 -23
  42. package/build/services/MiniappLauncher.d.ts.map +1 -1
  43. package/build/services/MiniappLauncher.js +30 -34
  44. package/build/services/MiniappLauncher.js.map +1 -1
  45. package/build/services/MiniappPauseCoordinator.d.ts +15 -0
  46. package/build/services/MiniappPauseCoordinator.d.ts.map +1 -0
  47. package/build/services/MiniappPauseCoordinator.js +93 -0
  48. package/build/services/MiniappPauseCoordinator.js.map +1 -0
  49. package/build/services/OfflineTranslationManager.d.ts +19 -0
  50. package/build/services/OfflineTranslationManager.d.ts.map +1 -0
  51. package/build/services/OfflineTranslationManager.js +35 -0
  52. package/build/services/OfflineTranslationManager.js.map +1 -0
  53. package/build/services/pages/OverlayPages.d.ts +5 -5
  54. package/build/services/pages/OverlayPages.d.ts.map +1 -1
  55. package/build/services/pages/OverlayPages.js +5 -5
  56. package/build/services/pages/OverlayPages.js.map +1 -1
  57. package/build/services/pages/overlayApps.d.ts +14 -11
  58. package/build/services/pages/overlayApps.d.ts.map +1 -1
  59. package/build/services/pages/overlayApps.js +21 -19
  60. package/build/services/pages/overlayApps.js.map +1 -1
  61. package/build/services/settingsBridge.d.ts +8 -8
  62. package/build/services/settingsBridge.d.ts.map +1 -1
  63. package/build/services/settingsBridge.js +12 -13
  64. package/build/services/settingsBridge.js.map +1 -1
  65. package/build/stores/apps.d.ts +0 -5
  66. package/build/stores/apps.d.ts.map +1 -1
  67. package/build/stores/apps.js +32 -52
  68. package/build/stores/apps.js.map +1 -1
  69. package/build/stores/settings.d.ts.map +1 -1
  70. package/build/stores/settings.js +0 -10
  71. package/build/stores/settings.js.map +1 -1
  72. package/build/types/applet.d.ts +3 -1
  73. package/build/types/applet.d.ts.map +1 -1
  74. package/build/types/applet.js.map +1 -1
  75. package/build/types/capabilities/even-realities-g1.d.ts.map +1 -1
  76. package/build/types/capabilities/even-realities-g1.js +1 -0
  77. package/build/types/capabilities/even-realities-g1.js.map +1 -1
  78. package/build/types/capabilities/even-realities-g2.d.ts.map +1 -1
  79. package/build/types/capabilities/even-realities-g2.js +1 -0
  80. package/build/types/capabilities/even-realities-g2.js.map +1 -1
  81. package/build/types/capabilities/mentra-display.d.ts.map +1 -1
  82. package/build/types/capabilities/mentra-display.js +1 -0
  83. package/build/types/capabilities/mentra-display.js.map +1 -1
  84. package/build/types/capabilities/nimo.d.ts.map +1 -1
  85. package/build/types/capabilities/nimo.js +1 -0
  86. package/build/types/capabilities/nimo.js.map +1 -1
  87. package/build/types/hardware.d.ts +9 -0
  88. package/build/types/hardware.d.ts.map +1 -1
  89. package/build/types/hardware.js +2 -0
  90. package/build/types/hardware.js.map +1 -1
  91. package/build/utils/miniappGlobals.js +1 -1
  92. package/build/utils/miniappGlobals.js.map +1 -1
  93. package/package.json +7 -7
  94. package/src/engine.ts +5 -0
  95. package/src/facades/dev.ts +1 -1
  96. package/src/facades/miniapps.ts +7 -7
  97. package/src/facades/speech.ts +12 -0
  98. package/src/generated/releaseMetadata.ts +3 -3
  99. package/src/index.ts +0 -2
  100. package/src/services/AppRegistry.ts +15 -27
  101. package/src/services/DevServerBridge.ts +2 -2
  102. package/src/services/DeviceEventRouter.ts +2 -0
  103. package/src/services/JSRouter.ts +1 -1
  104. package/src/services/LocalMiniappRuntime.ts +165 -66
  105. package/src/services/LocalTranslationService.ts +184 -0
  106. package/src/services/MiniappLauncher.ts +31 -34
  107. package/src/services/MiniappPauseCoordinator.ts +100 -0
  108. package/src/services/OfflineTranslationManager.ts +40 -0
  109. package/src/services/pages/OverlayPages.ts +5 -5
  110. package/src/services/pages/overlayApps.ts +19 -19
  111. package/src/services/settingsBridge.ts +12 -13
  112. package/src/stores/apps.ts +30 -58
  113. package/src/stores/settings.ts +0 -10
  114. package/src/types/applet.ts +3 -1
  115. package/src/types/capabilities/even-realities-g1.ts +1 -0
  116. package/src/types/capabilities/even-realities-g2.ts +1 -0
  117. package/src/types/capabilities/mentra-display.ts +1 -0
  118. package/src/types/capabilities/nimo.ts +1 -0
  119. package/src/types/hardware.ts +9 -0
  120. package/src/utils/miniappGlobals.ts +1 -1
  121. package/build/services/pages/activeDashboard.d.ts +0 -7
  122. package/build/services/pages/activeDashboard.d.ts.map +0 -1
  123. package/build/services/pages/activeDashboard.js +0 -21
  124. package/build/services/pages/activeDashboard.js.map +0 -1
  125. package/src/services/pages/activeDashboard.ts +0 -24
@@ -28,10 +28,9 @@ import {File} from "expo-file-system"
28
28
  import {permissions} from "../facades/permissions"
29
29
  import {decideDevLaunchRoute} from "../utils/devMiniappLaunch"
30
30
  import {storage} from "../utils/storage/storage"
31
- import appRegistry, {getLocalAppRunningState, saveLocalAppRunningState} from "./AppRegistry"
31
+ import appRegistry from "./AppRegistry"
32
32
  import devServerBridge from "./DevServerBridge"
33
33
  import localMiniappRuntime, {type InstalledMiniappManifest} from "./LocalMiniappRuntime"
34
- import {activeDashboardAmong} from "./pages/activeDashboard"
35
34
  import type {MiniappLaunchSource} from "@glassly/miniapp"
36
35
  import type {JSRouter} from "./JSRouter"
37
36
 
@@ -284,44 +283,43 @@ class MiniappLauncher {
284
283
  }
285
284
 
286
285
  /**
287
- * Re-spawn local miniapps that were running when the app was last killed.
286
+ * Start the Always On local miniapps at boot.
288
287
  *
289
288
  * Miniapps run in the phone's own JS engine, so a host-process kill tears
290
- * down their JSContext with nothing to resurrect them. Without this they
291
- * would silently stay stopped on the next launch even though the user left
292
- * them running.
289
+ * down their JSContext. Only Always On apps come back: whatever else was
290
+ * running when the app was killed stays stopped until the wearer opens it
291
+ * again. The spawn is headless (no foreground, no navigation) and registers
292
+ * the package so the home tray/switcher project it as running, exactly as a
293
+ * normal start would. The WebView, if any, re-attaches lazily when the user
294
+ * opens the app.
293
295
  *
294
- * Each local miniapp persists its running flag to disk on start/stop
295
- * (`saveLocalAppRunningState`, via the applet's `onStart`/`onStop`). We read
296
- * those flags back and re-spawn the background context headlessly (no
297
- * foreground, no navigation) — the spawn registers the package so the home
298
- * tray/switcher project it as running, exactly as a normal start would. The
299
- * WebView, if any, re-attaches lazily when the user opens the app.
296
+ * One dashboard runs at a time: a dashboard already running keeps its place,
297
+ * else the first Always On dashboard by package name starts and the rest
298
+ * stay stopped. The flag is exclusive after any user action (setAlwaysOn
299
+ * clears it on the other dashboards), so this only arbitrates manifests
300
+ * that both claim it.
300
301
  *
301
- * Idempotent and best-effort: skips already-spawned contexts, and clears the
302
- * persisted flag for any app whose bundle can no longer be resolved
303
- * (uninstalled, or dev server gone) so a dead entry doesn't retry every boot.
304
- * Not compatibility-gated: a previously-running background app shouldn't be
305
- * dropped just because glasses are momentarily disconnected at boot — it
306
- * resumes when they reconnect, same as mid-session.
307
- *
308
- * Always On apps (the dashboard) start whether or not they were running
309
- * when the app was last killed, and get a switcher card like any other app.
310
- * A dashboard that does not hold the role (ADR-0009) is not started by its
311
- * Always On flag.
312
- * Both restore paths skip apps missing required OS permissions without
313
- * prompting or clearing their saved running/Always On choice.
302
+ * Idempotent and best-effort: skips already-spawned contexts, and skips apps
303
+ * missing required OS permissions without prompting or clearing their Always
304
+ * On choice. Not compatibility-gated: an Always On app shouldn't be dropped
305
+ * just because glasses are momentarily disconnected at boot — it resumes
306
+ * when they reconnect, same as mid-session.
314
307
  */
315
308
  async autostartLocalMiniapps(): Promise<void> {
316
309
  const apps = await appRegistry.getInstalledMiniapps()
317
- const activeDashboard = activeDashboardAmong(apps)
310
+ const dashboards = apps.filter((app) => app.type === "dashboard")
311
+ const dashboard =
312
+ dashboards.find((app) => this.isRunning(app.packageName))?.packageName ??
313
+ dashboards
314
+ .filter((app) => app.alwaysOn)
315
+ .map((app) => app.packageName)
316
+ .sort()[0] ??
317
+ null
318
318
  for (const app of apps) {
319
- // Only phone-hosted miniapps have a JSContext to re-spawn. Offline
320
- // built-ins (`local:false, offline:true`) restore their native running
321
- // state elsewhere and have no bundle to launch.
322
- if (!app.local) continue
323
- const alwaysOn = app.alwaysOn && (app.type !== "dashboard" || app.packageName === activeDashboard)
324
- if (!alwaysOn && !getLocalAppRunningState(app.packageName)) continue
319
+ // Only phone-hosted miniapps have a JSContext to spawn. Offline
320
+ // built-ins (`local:false, offline:true`) have no bundle to launch.
321
+ if (!app.local || !app.alwaysOn) continue
322
+ if (app.type === "dashboard" && app.packageName !== dashboard) continue
325
323
  if (this.isRunning(app.packageName)) continue
326
324
  try {
327
325
  const missing = await permissions.missingRequired(app.permissions ?? [])
@@ -340,8 +338,7 @@ class MiniappLauncher {
340
338
  try {
341
339
  await this.ensureRunning(app.packageName)
342
340
  } catch (e) {
343
- console.warn(`MiniappLauncher: autostart failed for ${app.packageName} — clearing stale running flag`, e)
344
- saveLocalAppRunningState(app.packageName, false)
341
+ console.warn(`MiniappLauncher: autostart failed for ${app.packageName}`, e)
345
342
  }
346
343
  }
347
344
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * MiniappPauseCoordinator — pauses every local miniapp while the paired
3
+ * glasses are disconnected.
4
+ *
5
+ * A paused miniapp keeps its JSContext and state. The runtime tells it
6
+ * (`session.on("pause")`), stops fanning out streams to it, and drops the mic
7
+ * and transcription demand its subscriptions were holding, until the glasses
8
+ * are back. The pause waits out a grace window so a BLE blip does not pause
9
+ * and resume an app within a second; resume is immediate on reconnect.
10
+ *
11
+ * Only a phone paired to real glasses pauses. With nothing paired, or with the
12
+ * simulated device, there is nothing to reconnect to and miniapps keep running
13
+ * (the mirror and dev flows depend on that).
14
+ */
15
+ import {useGlassesStore} from "../stores/glasses"
16
+ import {BgTimer} from "../utils/timers"
17
+ import {isGlassesConnected, type GlassesConnectionStatus} from "./GlassesReadiness"
18
+ import {isPairedToRealGlasses} from "./PairedGlassesLatch"
19
+ import {projectPairingIdentity, subscribePairingIdentity, type IdentitySnapshot} from "./PairingIdentity"
20
+
21
+ export const MINIAPP_PAUSE_GRACE_MS = 10_000
22
+
23
+ export type MiniappPauseReason = "glasses_disconnected"
24
+
25
+ type Listener = (paused: boolean) => void
26
+
27
+ let started = false
28
+ let paused = false
29
+ let graceTimer: number | null = null
30
+ const listeners = new Set<Listener>()
31
+ let unsubscribers: Array<() => void> = []
32
+
33
+ /** Paired to real glasses that are not connected. */
34
+ export function shouldPauseMiniapps(identity: IdentitySnapshot, connection: GlassesConnectionStatus): boolean {
35
+ return isPairedToRealGlasses(identity) && !isGlassesConnected(connection)
36
+ }
37
+
38
+ export function isMiniappsPaused(): boolean {
39
+ return paused
40
+ }
41
+
42
+ /** Fires on pause/resume transitions only. */
43
+ export function onMiniappsPauseChange(listener: Listener): () => void {
44
+ listeners.add(listener)
45
+ return () => {
46
+ listeners.delete(listener)
47
+ }
48
+ }
49
+
50
+ function setPaused(next: boolean): void {
51
+ if (paused === next) return
52
+ paused = next
53
+ console.log(`MiniappPauseCoordinator: ${next ? "paused" : "resumed"} local miniapps`)
54
+ for (const listener of listeners) {
55
+ try {
56
+ listener(next)
57
+ } catch (e) {
58
+ console.warn("MiniappPauseCoordinator: listener threw", e)
59
+ }
60
+ }
61
+ }
62
+
63
+ function clearGrace(): void {
64
+ if (graceTimer === null) return
65
+ BgTimer.clearTimeout(graceTimer)
66
+ graceTimer = null
67
+ }
68
+
69
+ function wanted(): boolean {
70
+ return shouldPauseMiniapps(projectPairingIdentity(), useGlassesStore.getState().connection)
71
+ }
72
+
73
+ function evaluate(): void {
74
+ if (!wanted()) {
75
+ clearGrace()
76
+ setPaused(false)
77
+ return
78
+ }
79
+ if (paused || graceTimer !== null) return
80
+ graceTimer = BgTimer.setTimeout(() => {
81
+ graceTimer = null
82
+ if (wanted()) setPaused(true)
83
+ }, MINIAPP_PAUSE_GRACE_MS)
84
+ }
85
+
86
+ /** Start after the persisted settings are loaded so the pairing identity reads true. */
87
+ export function startMiniappPauseCoordinator(): void {
88
+ if (started) return
89
+ started = true
90
+ unsubscribers = [useGlassesStore.subscribe((s) => s.connection.state, evaluate), subscribePairingIdentity(evaluate)]
91
+ evaluate()
92
+ }
93
+
94
+ export function stopMiniappPauseCoordinator(): void {
95
+ for (const unsubscribe of unsubscribers) unsubscribe()
96
+ unsubscribers = []
97
+ clearGrace()
98
+ paused = false
99
+ started = false
100
+ }
@@ -0,0 +1,40 @@
1
+ import {Platform} from "react-native"
2
+ import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
3
+ import type {OfflineTranslationLanguage, OfflineTranslationStatus} from "@glassly/bluetooth-sdk"
4
+
5
+ export type {OfflineTranslationLanguage, OfflineTranslationStatus}
6
+
7
+ /** Headless on-device translation needs the iOS 26 Translation framework. */
8
+ export function offlineTranslationSupported(): boolean {
9
+ return Platform.OS === "ios" && parseInt(String(Platform.Version), 10) >= 26
10
+ }
11
+
12
+ /**
13
+ * On-device translation language packs. The OS owns the assets: there is no
14
+ * download progress or removal API, only install state and a system prompt
15
+ * that downloads a pair.
16
+ */
17
+ export const offlineTranslationManager = {
18
+ isSupported: offlineTranslationSupported,
19
+
20
+ /** Languages `source` can be translated into, with their install state. */
21
+ async languages(source: string): Promise<OfflineTranslationLanguage[]> {
22
+ if (!offlineTranslationSupported()) return []
23
+ return BluetoothSdk.getOfflineTranslationLanguages(source)
24
+ },
25
+
26
+ async status(source: string, target: string): Promise<OfflineTranslationStatus> {
27
+ if (!offlineTranslationSupported()) return "unavailable"
28
+ return BluetoothSdk.getOfflineTranslationStatus(source, target)
29
+ },
30
+
31
+ /** Shows the system download prompt; resolves with the pair's status afterwards. */
32
+ async download(source: string, target: string): Promise<OfflineTranslationStatus> {
33
+ if (!offlineTranslationSupported()) return "unavailable"
34
+ return BluetoothSdk.downloadOfflineTranslation(source, target)
35
+ },
36
+
37
+ translate(text: string, source: string, target: string): Promise<string> {
38
+ return BluetoothSdk.translateOffline(text, source, target)
39
+ },
40
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
- * The overlay app's page (ADR-0006, ADR-0009): one Page per dashboard that
2
+ * The overlay app's page (ADR-0006, ADR-0010): one Page per dashboard that
3
3
  * has taken the slot, the only Page implementation the page host shows. The
4
4
  * engine keeps the slot and the eligibility watch; a page only relays:
5
5
  * holding the slot is reported to its app as a view change, and the app's
6
6
  * frames reach the slot through `renderOverlay` under its package name. Only
7
- * the dashboard that holds the role may show; when the role moves, the
8
- * previous holder hears it was replaced. The runtime supplies the sink that
9
- * delivers to apps, so this module never imports it.
7
+ * the connected dashboard may show; a stopping one is removed without a
8
+ * word. The runtime supplies the sink that delivers to apps, so this module
9
+ * never imports it.
10
10
  */
11
11
  import pageHost, {type Page, type PageHiddenReason} from "./PageHost"
12
12
  import {isOverlayApp} from "./overlayApps"
@@ -45,7 +45,7 @@ class OverlayPages {
45
45
  return pageHost.showing === page
46
46
  }
47
47
 
48
- /** Release the slot if the app holds it; "replaced" when the dashboard role moved on. */
48
+ /** Release the slot if the app holds it. */
49
49
  hide(packageName: string, reason: PageHiddenReason = "hidden"): void {
50
50
  const page = this.pages.get(packageName)
51
51
  if (page) pageHost.hide(page, reason)
@@ -1,12 +1,14 @@
1
1
  /**
2
- * The overlay app: the connected miniapp that holds the dashboard role
3
- * (ADR-0009). It alone may take the overlay slot through
4
- * `session.display.showView("dashboard")` and has "dashboard" in its
5
- * `display.views` capability while the glasses are eligible. The runtime
6
- * records each dashboard miniapp (manifest type "dashboard") as it connects
7
- * and stops, and who holds the role; the engine never knows what a dashboard
8
- * draws. Kept free of imports so the runtime and the notification policy can
9
- * both read it without a cycle.
2
+ * The overlay app: the connected dashboard miniapp (ADR-0010). It alone may
3
+ * take the overlay slot through `session.display.showView("dashboard")` and
4
+ * has "dashboard" in its `display.views` capability while the glasses are
5
+ * eligible. The runtime records each dashboard miniapp (manifest type
6
+ * "dashboard") as it connects and stops; the first connected one holds the
7
+ * role until it stops, and the engine never knows what a dashboard draws.
8
+ * Only one dashboard runs at a time (the apps store stops the previous one
9
+ * before starting another), so two connected at once is a transient. Kept
10
+ * free of imports so the runtime and the notification policy can both read
11
+ * it without a cycle.
10
12
  */
11
13
 
12
14
  export type DisplayView = "main" | "dashboard"
@@ -18,9 +20,9 @@ const listening = new Set<string>()
18
20
  let active: string | null = null
19
21
  const listeners = new Set<() => void>()
20
22
 
21
- /** `packageName` holds the dashboard role and is connected. */
23
+ /** `packageName` holds the dashboard role. */
22
24
  export function isOverlayApp(packageName: string): boolean {
23
- return packageName === active && connected.has(packageName)
25
+ return packageName === active
24
26
  }
25
27
 
26
28
  /** `packageName` is a connected dashboard miniapp, whether or not it holds the role. */
@@ -29,29 +31,27 @@ export function isDashboardApp(packageName: string): boolean {
29
31
  }
30
32
 
31
33
  export function overlayApps(): readonly string[] {
32
- return active !== null && connected.has(active) ? [active] : []
34
+ return active !== null ? [active] : []
33
35
  }
34
36
 
35
- /** The package that holds the dashboard role, connected or not. */
37
+ /** The connected dashboard that holds the role, or null for nobody. */
36
38
  export function activeDashboard(): string | null {
37
39
  return active
38
40
  }
39
41
 
40
- export function setActiveDashboard(packageName: string | null): void {
41
- if (active === packageName) return
42
- active = packageName
43
- emit()
44
- }
45
-
42
+ /** A dashboard connected: it takes the role when nobody holds it. */
46
43
  export function addDashboardApp(packageName: string): void {
47
44
  if (connected.has(packageName)) return
48
45
  connected.add(packageName)
46
+ if (active === null) active = packageName
49
47
  emit()
50
48
  }
51
49
 
50
+ /** A dashboard stopped: the role passes to another connected one, by package name, else to nobody. */
52
51
  export function removeDashboardApp(packageName: string): void {
53
52
  listening.delete(packageName)
54
53
  if (!connected.delete(packageName)) return
54
+ if (active === packageName) active = [...connected].sort()[0] ?? null
55
55
  emit()
56
56
  }
57
57
 
@@ -65,7 +65,7 @@ export function setOverlayViewer(packageName: string, subscribed: boolean): void
65
65
 
66
66
  /** The overlay app presents phone notifications. */
67
67
  export function overlayViewerRunning(): boolean {
68
- return active !== null && connected.has(active) && listening.has(active)
68
+ return active !== null && listening.has(active)
69
69
  }
70
70
 
71
71
  export function onOverlayAppsChange(listener: () => void): () => void {
@@ -5,10 +5,10 @@
5
5
  * request validation. Pure, so the runtime's handlers stay thin and this can
6
6
  * be tested without the native import surface the runtime pulls in.
7
7
  *
8
- * A dashboard miniapp is the usual consumer: it edits the notification and
9
- * glasses-menu keys the phone's settings screens used to, through the store,
10
- * so persistence, server sync and the Bluetooth sync of `menu_apps` behave as
11
- * if a phone screen wrote them. A dashboard's own look is not here; it keeps
8
+ * A dashboard miniapp is the usual consumer: it edits the notification,
9
+ * glasses-menu and display-position keys the phone's settings screens used
10
+ * to, through the store, so persistence, server sync and the Bluetooth sync
11
+ * of `menu_apps` and the position behave as if a phone screen wrote them. A dashboard's own look is not here; it keeps
12
12
  * that in its storage.
13
13
  */
14
14
 
@@ -19,6 +19,10 @@ export const SETTINGS_BRIDGE_KEYS: Readonly<Record<string, SettingsBridgeAccess>
19
19
  // The dashboard's master switch is a phone-side debug knob.
20
20
  custom_dashboard_enabled: "read",
21
21
  contextual_dashboard: "write",
22
+ head_up_angle: "write",
23
+ // Where the glasses draw; each model's steps are its display.positionRange.
24
+ dashboard_depth: "write",
25
+ dashboard_height: "write",
22
26
  twelve_hour_time: "write",
23
27
  menu_apps: "write",
24
28
  notifications_enabled: "write",
@@ -33,16 +37,11 @@ export const SETTINGS_BRIDGE_KEYS: Readonly<Record<string, SettingsBridgeAccess>
33
37
 
34
38
  /**
35
39
  * Read-only keys the engine computes: whether the wearer can see the phone,
36
- * whether the foreground miniapp is drawing on the glasses, the ring's
37
- * battery, and the package that holds the dashboard role (null for nobody).
38
- * They change on their own clocks; the runtime pushes them like stored keys.
40
+ * whether the foreground miniapp is drawing on the glasses, and the ring's
41
+ * battery. They change on their own clocks; the runtime pushes them like
42
+ * stored keys.
39
43
  */
40
- export const DERIVED_SETTINGS_KEYS = [
41
- "phone_active",
42
- "foreground_app_on_screen",
43
- "ring_battery",
44
- "active_dashboard",
45
- ] as const
44
+ export const DERIVED_SETTINGS_KEYS = ["phone_active", "foreground_app_on_screen", "ring_battery"] as const
46
45
  export type DerivedSettingsKey = (typeof DERIVED_SETTINGS_KEYS)[number]
47
46
 
48
47
  export function isDerivedSettingsKey(key: string): key is DerivedSettingsKey {
@@ -24,8 +24,8 @@ import localDisplayManager from "../services/LocalDisplayManager"
24
24
  import {islandNotifications} from "../services/NotificationsEmitter"
25
25
  import sttModelManager from "../services/STTModelManager"
26
26
  import {miniappLauncher} from "../services/MiniappLauncher"
27
+ import {isMiniappsPaused, onMiniappsPauseChange} from "../services/MiniappPauseCoordinator"
27
28
  import {miniappRunningRegistry} from "../services/MiniappRunningRegistry"
28
- import {activeDashboardAmong, resolveActiveDashboard} from "../services/pages/activeDashboard"
29
29
  import {defaultHiddenStatus, isGatedByDevTools, isGatedUntilPaired, resolveHiddenStatus} from "./appVisibility"
30
30
  import {SETTINGS, useSettingsStore} from "./settings"
31
31
  import BluetoothSdk from "@glassly/bluetooth-sdk"
@@ -102,7 +102,6 @@ interface AppStatusState {
102
102
  * off leaves it running. Persists the wearer's choice beside the hidden flag.
103
103
  */
104
104
  setAlwaysOn: (packageName: string, status: boolean) => Promise<void>
105
- setActiveDashboard: (packageName: string) => Promise<void>
106
105
  /** Replace the whole apps array. Hosts use this when their extra source changes. */
107
106
  setApps: (apps: ClientApp[]) => void
108
107
  }
@@ -255,6 +254,7 @@ function projectApps(previousState: AppStatusState, localApps: ClientApp[]): Cli
255
254
  // through to the root router (restart). Deriving here keeps the flag stable
256
255
  // across both passes.
257
256
  foregrounded: app.packageName === previousState.foregroundedPackage,
257
+ paused: app.local && app.running && isMiniappsPaused(),
258
258
  }
259
259
  const prev = previousByPackage.get(app.packageName)
260
260
  return prev && shallowEqualApp(prev, next) ? prev : next
@@ -379,12 +379,11 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
379
379
  return true
380
380
  }
381
381
 
382
- // Foreground-only-one rule: stop other running standard apps.
383
- if (app.type === "standard") {
384
- const runningForeground = state.apps.filter(
385
- (a) => a.running && a.type === "standard" && a.packageName !== packageName,
386
- )
387
- for (const r of runningForeground) {
382
+ // Only-one rules: one standard app in the foreground, one dashboard
383
+ // drawing the overlay. Starting another stops the one running.
384
+ if (app.type === "standard" || app.type === "dashboard") {
385
+ const runningPeers = state.apps.filter((a) => a.running && a.type === app.type && a.packageName !== packageName)
386
+ for (const r of runningPeers) {
388
387
  await get().stop(r.packageName)
389
388
  }
390
389
  }
@@ -413,9 +412,7 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
413
412
  set((s) => ({
414
413
  apps: s.apps.map((a) => (a.packageName === packageName ? {...a, running: false, loading: false} : a)),
415
414
  }))
416
- // onStart already persisted running:true to disk (saveLocalAppRunningState)
417
- // before the spawn; run onStop to undo it so a refresh/reboot doesn't
418
- // resurrect a "running" app with no JS context.
415
+ // onStart already ran before the spawn; run onStop to undo it.
419
416
  await startStopApp(app, false)
420
417
  return false
421
418
  }
@@ -508,17 +505,9 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
508
505
 
509
506
  uninstall: (packageName, version) => {
510
507
  return Res.try_async(async () => {
511
- const wasActiveDashboard = activeDashboardAmong(get().apps) === packageName
512
508
  const res = await appRegistry.uninstall(packageName, version)
513
509
  if (res.is_error()) throw res.error
514
510
  set((s) => ({apps: s.apps.filter((a) => a.packageName !== packageName)}))
515
- // The dashboard role fell back to another dashboard, which was stopped
516
- // when it lost the role: start it, as launch would have.
517
- if (wasActiveDashboard) {
518
- const apps = get().apps
519
- const heir = apps.find((a) => a.packageName === activeDashboardAmong(apps))
520
- if (heir?.alwaysOn && !heir.running) await get().start(heir, {skipNavigation: true})
521
- }
522
511
  })
523
512
  },
524
513
 
@@ -556,46 +545,49 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
556
545
  setAlwaysOn: async (packageName: string, status: boolean) => {
557
546
  const app = get().apps.find((a) => a.packageName === packageName)
558
547
  if (!app) return
548
+ // One dashboard is Always On at a time: marking this one clears the others.
549
+ const cleared =
550
+ status && app.type === "dashboard"
551
+ ? get().apps.filter((a) => a.type === "dashboard" && a.alwaysOn && a.packageName !== packageName)
552
+ : []
559
553
  saveAlwaysOnOverride(packageName, status)
554
+ for (const other of cleared) saveAlwaysOnOverride(other.packageName, false)
560
555
  set((s) => ({
561
- apps: s.apps.map((a) => (a.packageName === packageName ? {...a, alwaysOn: status} : a)),
556
+ apps: s.apps.map((a) => {
557
+ if (a.packageName === packageName) return {...a, alwaysOn: status}
558
+ if (cleared.includes(a)) return {...a, alwaysOn: false}
559
+ return a
560
+ }),
562
561
  }))
563
562
  if (status && !app.running) await get().start(app, {skipNavigation: true})
564
563
  },
565
564
 
566
- // The dashboard role (ADR-0009) moves to `packageName`: the previous holder
567
- // stops and the new one starts in the background.
568
- setActiveDashboard: async (packageName: string) => {
569
- const apps = get().apps
570
- const next = apps.find((a) => a.packageName === packageName && a.type === "dashboard")
571
- if (!next) return
572
- const previous = apps.find((a) => a.packageName === activeDashboardAmong(apps))
573
- await useSettingsStore.getState().setSetting(SETTINGS.active_dashboard.key, packageName)
574
- if (previous?.running && previous.packageName !== packageName) await get().stop(previous.packageName)
575
- if (!next.running) await get().start(next, {skipNavigation: true})
576
- },
577
-
578
565
  setApps: (apps) => set({apps}),
579
566
  }))
580
567
 
581
568
  // Project miniappRunningRegistry membership into the store's `running` field
582
- // for local apps so the home tray and switcher reflect actual mount state
583
- // without waiting for the next refresh() cycle.
584
- miniappRunningRegistry.subscribe(() => {
569
+ // (and the pause coordinator's state into `paused`) for local apps so the home
570
+ // tray and switcher reflect actual mount state without waiting for the next
571
+ // refresh() cycle.
572
+ function projectLocalRunningState(): void {
585
573
  const running = new Set(miniappRunningRegistry.getAll())
574
+ const pausedNow = isMiniappsPaused()
586
575
  const state = useAppStatusStore.getState()
587
576
  let changed = false
588
577
  const updated = state.apps.map((app) => {
589
578
  if (!app.local) return app
590
579
  const next = running.has(app.packageName)
591
- if (app.running === next) return app
580
+ const paused = next && pausedNow
581
+ if (app.running === next && (app.paused ?? false) === paused) return app
592
582
  changed = true
593
- return {...app, running: next}
583
+ return {...app, running: next, paused}
594
584
  })
595
585
  if (changed) {
596
586
  useAppStatusStore.setState({apps: updated})
597
587
  }
598
- })
588
+ }
589
+ miniappRunningRegistry.subscribe(projectLocalRunningState)
590
+ onMiniappsPauseChange(projectLocalRunningState)
599
591
 
600
592
  // The display-manager's core (foreground) slot is DERIVED state: always the
601
593
  // currently-running standard app, no matter HOW it came to run — user tap
@@ -677,26 +669,6 @@ export const useRunningAlwaysOnApps = () => {
677
669
  return useMemo(() => apps.filter((app) => app.alwaysOn && app.running), [apps])
678
670
  }
679
671
 
680
- /** Installed miniapps that can hold the dashboard role: manifest type "dashboard". */
681
- export const useDashboards = () => {
682
- const apps = useApps()
683
- return useMemo(() => apps.filter((app) => app.type === "dashboard"), [apps])
684
- }
685
-
686
- /** The miniapp that holds the dashboard role (ADR-0009), or null when none is installed. */
687
- export const useActiveDashboard = (): ClientApp | null => {
688
- const dashboards = useDashboards()
689
- const chosen = useSettingsStore((s) => s.getSetting(SETTINGS.active_dashboard.key))
690
- return useMemo(() => {
691
- const packageName = resolveActiveDashboard(
692
- dashboards.map((app) => app.packageName),
693
- chosen,
694
- SETTINGS.active_dashboard.defaultValue(),
695
- )
696
- return dashboards.find((app) => app.packageName === packageName) ?? null
697
- }, [dashboards, chosen])
698
- }
699
-
700
672
  /** Apps that run without a foreground surface: background ones and dashboards. */
701
673
  const isBackgroundApp = (app: ClientApp): boolean => app.type === "background" || app.type === "dashboard"
702
674
 
@@ -806,16 +806,6 @@ export const SETTINGS: Record<string, Setting> = {
806
806
  saveOnServer: false,
807
807
  persist: true,
808
808
  },
809
- // The wearer's choice of dashboard miniapp (ADR-0009). Installs are
810
- // per-phone, so the choice is device-local. The default is the bundled
811
- // dashboard, which the role falls back to when the choice is not installed.
812
- active_dashboard: {
813
- key: "active_dashboard",
814
- defaultValue: () => "com.glassly.dashboard",
815
- writable: true,
816
- saveOnServer: false,
817
- persist: true,
818
- },
819
809
  head_up_angle: {key: "head_up_angle", defaultValue: () => 45, writable: true, saveOnServer: true, persist: true},
820
810
  brightness: {key: "brightness", defaultValue: () => 50, writable: true, saveOnServer: true, persist: true},
821
811
  auto_brightness: {
@@ -81,7 +81,7 @@ export interface AppletInterface {
81
81
  * outside the manifest: offline routing, hardware compatibility result,
82
82
  * loading/hidden/local flags, lifecycle hooks, screenshot, and the
83
83
  * dev-miniapp escape hatches (`devUrl` / `isMiniappDev`) that surface a
84
- * `glassly-miniapp dev` snapshot.
84
+ * `glassly dev` snapshot.
85
85
  */
86
86
  /**
87
87
  * A manifest-declared action a miniapp exposes for other miniapps to invoke
@@ -116,6 +116,8 @@ export interface ClientApp extends AppletInterface {
116
116
  * the wearer's choice persists beside the hidden flag.
117
117
  */
118
118
  alwaysOn?: boolean
119
+ /** Running, but paused while the paired glasses are disconnected (MiniappPauseCoordinator). */
120
+ paused?: boolean
119
121
  onStart?: () => void
120
122
  onStop?: () => void
121
123
  screenshot?: string
@@ -28,6 +28,7 @@ export const evenRealitiesG1: Capabilities = {
28
28
  fieldOfView: {horizontal: 25},
29
29
  maxTextLines: 5,
30
30
  adjustBrightness: true,
31
+ positionRange: {height: {min: 0, max: 8}, depth: {min: 1, max: 9}},
31
32
 
32
33
  // Scene display API — no positioned elements; scenes degrade to text walls.
33
34
  canPosition: false,
@@ -29,6 +29,7 @@ export const evenRealitiesG2: Capabilities = {
29
29
  fieldOfView: {horizontal: 25},
30
30
  maxTextLines: 8,
31
31
  adjustBrightness: true,
32
+ positionRange: {height: {min: 0, max: 12}, depth: {min: 1, max: 3}},
32
33
 
33
34
  // Scene display API — EvenHub retained containers.
34
35
  width: 576,
@@ -28,6 +28,7 @@ export const mentraDisplay: Capabilities = {
28
28
  fieldOfView: {horizontal: 25},
29
29
  maxTextLines: 5,
30
30
  adjustBrightness: true,
31
+ positionRange: {height: {min: 0, max: 8}, depth: {min: 1, max: 4}},
31
32
 
32
33
  // Scene display API — firmware canvas components on the 500x220 virtual
33
34
  // screen centered in the 640x480 A6N panel (mos_display_config.c).
@@ -32,6 +32,7 @@ export const nimo: Capabilities = {
32
32
  resolution: {width: 640, height: 400},
33
33
  maxTextLines: 5,
34
34
  adjustBrightness: true,
35
+ positionRange: {height: {min: 0, max: 10}, depth: {min: 0, max: 10}},
35
36
  },
36
37
 
37
38
  // Microphone capabilities - Nimo streams Opus audio from both arms