@glassly/engine 0.1.0-dev.62 → 0.1.0-dev.90

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 (85) hide show
  1. package/README.md +1 -1
  2. package/build/engine.d.ts +7 -2
  3. package/build/engine.d.ts.map +1 -1
  4. package/build/engine.js +3 -0
  5. package/build/engine.js.map +1 -1
  6. package/build/facades/display.d.ts +10 -3
  7. package/build/facades/display.d.ts.map +1 -1
  8. package/build/facades/display.js +11 -1
  9. package/build/facades/display.js.map +1 -1
  10. package/build/facades/displayMirror.d.ts +2 -6
  11. package/build/facades/displayMirror.d.ts.map +1 -1
  12. package/build/facades/displayMirror.js +7 -14
  13. package/build/facades/displayMirror.js.map +1 -1
  14. package/build/facades/glasses.d.ts +1 -0
  15. package/build/facades/glasses.d.ts.map +1 -1
  16. package/build/facades/glasses.js +2 -0
  17. package/build/facades/glasses.js.map +1 -1
  18. package/build/facades/phoneNotifications.d.ts +12 -2
  19. package/build/facades/phoneNotifications.d.ts.map +1 -1
  20. package/build/facades/phoneNotifications.js +30 -10
  21. package/build/facades/phoneNotifications.js.map +1 -1
  22. package/build/generated/releaseMetadata.js +3 -3
  23. package/build/generated/releaseMetadata.js.map +1 -1
  24. package/build/services/CustomDashboardService.d.ts +11 -10
  25. package/build/services/CustomDashboardService.d.ts.map +1 -1
  26. package/build/services/CustomDashboardService.js +70 -69
  27. package/build/services/CustomDashboardService.js.map +1 -1
  28. package/build/services/DashboardCoordinator.d.ts +26 -0
  29. package/build/services/DashboardCoordinator.d.ts.map +1 -0
  30. package/build/services/DashboardCoordinator.js +125 -0
  31. package/build/services/DashboardCoordinator.js.map +1 -0
  32. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  33. package/build/services/DeviceEventRouter.js +5 -7
  34. package/build/services/DeviceEventRouter.js.map +1 -1
  35. package/build/services/LocalDisplayManager.d.ts +10 -0
  36. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  37. package/build/services/LocalDisplayManager.js +66 -12
  38. package/build/services/LocalDisplayManager.js.map +1 -1
  39. package/build/services/PhoneNotificationsSync.d.ts +13 -0
  40. package/build/services/PhoneNotificationsSync.d.ts.map +1 -1
  41. package/build/services/PhoneNotificationsSync.js +101 -9
  42. package/build/services/PhoneNotificationsSync.js.map +1 -1
  43. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  44. package/build/services/PhoneStreamCoordinator.js +9 -6
  45. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  46. package/build/services/SceneRenderer.d.ts.map +1 -1
  47. package/build/services/SceneRenderer.js +5 -0
  48. package/build/services/SceneRenderer.js.map +1 -1
  49. package/build/services/effectiveCapabilities.d.ts +1 -1
  50. package/build/services/effectiveCapabilities.d.ts.map +1 -1
  51. package/build/services/effectiveCapabilities.js +9 -4
  52. package/build/services/effectiveCapabilities.js.map +1 -1
  53. package/build/services/iosNotificationAppCatalog.d.ts +13 -0
  54. package/build/services/iosNotificationAppCatalog.d.ts.map +1 -0
  55. package/build/services/iosNotificationAppCatalog.js +28 -0
  56. package/build/services/iosNotificationAppCatalog.js.map +1 -0
  57. package/build/stores/display.d.ts +4 -4
  58. package/build/stores/display.d.ts.map +1 -1
  59. package/build/stores/display.js +12 -36
  60. package/build/stores/display.js.map +1 -1
  61. package/build/stores/glasses.d.ts.map +1 -1
  62. package/build/stores/glasses.js +2 -0
  63. package/build/stores/glasses.js.map +1 -1
  64. package/build/stores/settings.d.ts.map +1 -1
  65. package/build/stores/settings.js +53 -2
  66. package/build/stores/settings.js.map +1 -1
  67. package/package.json +7 -7
  68. package/src/engine.ts +3 -0
  69. package/src/facades/display.ts +11 -1
  70. package/src/facades/displayMirror.ts +7 -15
  71. package/src/facades/glasses.ts +2 -0
  72. package/src/facades/phoneNotifications.ts +32 -10
  73. package/src/generated/releaseMetadata.ts +3 -3
  74. package/src/services/CustomDashboardService.ts +67 -66
  75. package/src/services/DashboardCoordinator.ts +121 -0
  76. package/src/services/DeviceEventRouter.ts +5 -7
  77. package/src/services/LocalDisplayManager.ts +66 -14
  78. package/src/services/PhoneNotificationsSync.ts +125 -9
  79. package/src/services/PhoneStreamCoordinator.ts +10 -6
  80. package/src/services/SceneRenderer.ts +4 -0
  81. package/src/services/effectiveCapabilities.ts +9 -4
  82. package/src/services/iosNotificationAppCatalog.ts +39 -0
  83. package/src/stores/display.ts +16 -48
  84. package/src/stores/glasses.ts +2 -0
  85. package/src/stores/settings.ts +53 -2
@@ -1,25 +1,40 @@
1
1
  /**
2
2
  * phoneNotifications facade — `engine.phoneNotifications`: forward phone
3
- * notifications to the glasses, with a per-app blocklist. Android-only (the
4
- * NotificationListenerService); the getters return sensible defaults on iOS.
3
+ * notifications to the glasses, with a per-app blocklist.
5
4
  *
6
- * The per-app blocklist is an engine setting (auto-synced to the native
7
- * listener); the installed-apps list + listener permission are CrustModule.
8
- * Capture itself is controlled only by Android notification access and the
9
- * hidden operational kill switch.
5
+ * Android: apps come from the NotificationListenerService's installed-apps
6
+ * enumeration and the blocklist filters capture phone-side.
7
+ * iOS: apps are the seeded catalog plus what the glasses' ANCS client has
8
+ * reported posting notifications (`ios_notification_seen_apps`); the blocklist
9
+ * is mapped onto the G2's on-glass allowlist by PhoneNotificationsSync.
10
+ * Capture itself is controlled by Android notification access / iOS ANCS
11
+ * authorization (`glasses.ancsAuthorized`), not by this facade.
10
12
  */
11
13
  import {Platform} from "react-native"
12
14
  import CrustModule from "@glassly/crust"
13
15
  import {useSettingsStore, SETTINGS} from "../stores/settings"
16
+ import {useGlassesStore} from "../stores/glasses"
17
+ import {getKnownNotificationApps} from "../services/PhoneNotificationsSync"
14
18
 
15
19
  export const phoneNotifications = {
16
- /** The phone's installed apps (package, name, icon). (empty on iOS) */
20
+ /**
21
+ * Apps to show in the notification filter UI (package/bundle id, name, icon).
22
+ * Android: installed apps. iOS: seeded catalog ∪ glasses-reported apps
23
+ * (icons unavailable — iOS offers no cross-app icon lookup).
24
+ */
17
25
  installedApps: (): Promise<Array<{packageName: string; appName: string; icon: string | null}>> => {
18
- if (Platform.OS !== "android") return Promise.resolve([])
19
- return CrustModule.getInstalledApps()
26
+ if (Platform.OS === "android") return CrustModule.getInstalledApps()
27
+ if (Platform.OS !== "ios") return Promise.resolve([])
28
+ return Promise.resolve(
29
+ getKnownNotificationApps().map((app) => ({
30
+ packageName: app.appId,
31
+ appName: app.displayName,
32
+ icon: null,
33
+ })),
34
+ )
20
35
  },
21
36
 
22
- /** The per-app blocklist (package names whose notifications are NOT forwarded). */
37
+ /** The per-app blocklist (package/bundle ids whose notifications are NOT forwarded). */
23
38
  blocklist: (): string[] => {
24
39
  const b = useSettingsStore.getState().getSetting(SETTINGS.notifications_blocklist.key)
25
40
  return Array.isArray(b) ? b : []
@@ -38,4 +53,11 @@ export const phoneNotifications = {
38
53
  if (Platform.OS !== "android") return
39
54
  return CrustModule.openNotificationListenerSettings()
40
55
  },
56
+
57
+ /**
58
+ * iOS: whether the connected glasses may read the phone's ANCS notification
59
+ * stream (the "Share System Notifications" Bluetooth consent). Always false
60
+ * on Android — Android capture is the notification listener instead.
61
+ */
62
+ ancsAuthorized: (): boolean => Boolean(useGlassesStore.getState().ancsAuthorized),
41
63
  }
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "0.1.0",
15
- "releaseIdentity": "0.1.0-dev.62",
16
- "releaseSetId": "glassly-0.1.0-dev.62",
17
- "sourceCommit": "69eee438b882fca1c9b0ad466d71fc2c24053662",
15
+ "releaseIdentity": "0.1.0-dev.90",
16
+ "releaseSetId": "glassly-0.1.0-dev.90",
17
+ "sourceCommit": "1140c7490313353cb1d8da0782bf74c87d9119eb",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
@@ -2,9 +2,8 @@
2
2
  * Scene API dashboard for G2 custom firmware.
3
3
  *
4
4
  * G2.swift marks the published firmware version after it detects the CFW
5
- * capability advertisement. The service retains a dashboard frame, owns
6
- * dashboard gestures while head-up is active, and leaves every other device
7
- * on its native behavior.
5
+ * capability advertisement. DashboardCoordinator owns open/close and display
6
+ * switching; this service renders the widgets and handles gestures while open.
8
7
  *
9
8
  * The dashboard is composed of user-orderable widgets (clock, calendar, apps
10
9
  * button, notifications button) stacked vertically on the CFW scene canvas.
@@ -19,6 +18,7 @@ import {useAppStatusStore} from "../stores/apps"
19
18
  import {useGlassesStore} from "../stores/glasses"
20
19
  import {SETTINGS, useSettingsStore} from "../stores/settings"
21
20
  import type {ClientApp} from "../types/applet"
21
+ import {TextMeasurer} from "../utils/display/measurer"
22
22
  import type {SceneElementInput} from "../utils/display/scene"
23
23
  import {BgTimer} from "../utils/timers"
24
24
  import {
@@ -32,13 +32,17 @@ import {
32
32
  type DashboardWidgetSetting,
33
33
  type MenuAppSetting,
34
34
  } from "./CustomDashboardModel"
35
+ import displayProcessor from "./DisplayProcessor"
35
36
  import {isGlassesConnected} from "./GlassesReadiness"
37
+ import localDisplayManager from "./LocalDisplayManager"
36
38
  import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
37
39
  import sceneRenderer from "./SceneRenderer"
38
40
 
39
41
  const DASHBOARD_APP_ID = "system.custom-dashboard"
40
42
  const CLOCK_REFRESH_MS = 30_000
41
43
  const CALENDAR_REFRESH_MS = 5 * 60_000
44
+ /** Matches the boot message's window; stops have no unmount-driven end signal. */
45
+ const STOP_NOTICE_MS = 1500
42
46
 
43
47
  // CFW blits the stock compositor's 576×288 canvas; used when the firmware has
44
48
  // not reported scene capabilities yet.
@@ -49,6 +53,15 @@ const WIDGET_GAP = 10
49
53
  /** Row height for the glasses' builtin 20px font engine. */
50
54
  const LINE_H = 24
51
55
  const BUTTON_H = 34
56
+ /**
57
+ * Selection marker: a text-hugging bar under the focused label instead of a
58
+ * boxed border. Drawn as a rect (h 3, border 2 — the strokes overlap into a
59
+ * solid bar) because mode 15 renders only glyphs; the bar must be in the
60
+ * raster or the firmware never shows it.
61
+ */
62
+ const UNDERLINE_H = 3
63
+ /** Row-top → underline-top: just under the builtin font's 27px line. */
64
+ const UNDERLINE_OFFSET_Y = BUTTON_H - 4
52
65
  /**
53
66
  * App-menu icon grid: 2 rows × 4 columns per page. CFW rasterizes scenes
54
67
  * phone-side, so the stock firmware's 4-image container pool does not bound
@@ -73,6 +86,8 @@ function eventLine(event: CalendarEvent): string {
73
86
  class CustomDashboardService {
74
87
  private started = false
75
88
  private open = false
89
+ /** Set by DashboardCoordinator; asks it to close (and restore main content). */
90
+ public onRequestClose: (() => void) | null = null
76
91
  private view: "main" | "apps" = "main"
77
92
  private mainIndex = 0
78
93
  private appsIndex = 0
@@ -129,30 +144,22 @@ class CustomDashboardService {
129
144
  if (this.isEligible()) void this.refreshCalendarAndRender()
130
145
  }, CALENDAR_REFRESH_MS)
131
146
  this.eligible = this.isEligible()
132
- if (this.eligible) {
133
- this.ensureContextualDashboard()
134
- void this.refreshCalendarAndRender()
135
- }
136
147
  }
137
148
 
138
149
  /**
139
150
  * Track eligible⇄ineligible transitions from any trigger (setting toggle,
140
- * connect/disconnect, firmware change). Entering enables the contextual
141
- * dashboard once — not on every settings write, so a user's manual
142
- * contextual-dashboard choice is no longer ratcheted back. Leaving closes
143
- * the dashboard, blanks the glasses view, and drops the retained scene so a
144
- * stale frame can't be replayed after the feature was turned off.
151
+ * connect/disconnect, firmware change). Leaving closes the dashboard via the
152
+ * coordinator (which restores main content) and drops the retained scene so
153
+ * a stale frame can't be replayed after the feature was turned off.
145
154
  */
146
155
  private syncEligibility(): boolean {
147
156
  const eligible = this.isEligible()
148
157
  if (eligible === this.eligible) return eligible
149
158
  this.eligible = eligible
150
- if (eligible) {
151
- this.ensureContextualDashboard()
152
- } else {
159
+ if (!eligible) {
160
+ if (this.open) this.onRequestClose?.()
153
161
  this.open = false
154
162
  this.resetNavigation()
155
- sceneRenderer.emitScene(DASHBOARD_APP_ID, "dashboard", [])
156
163
  sceneRenderer.clearApp(DASHBOARD_APP_ID)
157
164
  }
158
165
  return eligible
@@ -165,6 +172,7 @@ class CustomDashboardService {
165
172
  if (this.calendarTimer !== null) BgTimer.clearInterval(this.calendarTimer)
166
173
  this.clockTimer = null
167
174
  this.calendarTimer = null
175
+ if (this.open) this.onRequestClose?.()
168
176
  this.open = false
169
177
  this.eligible = false
170
178
  this.resetNavigation()
@@ -188,29 +196,17 @@ class CustomDashboardService {
188
196
  return this.open
189
197
  }
190
198
 
191
- setHeadUp(up: boolean): void {
192
- if (!this.isEligible()) {
193
- this.open = false
194
- return
195
- }
196
- if (up && !this.open) this.resetNavigation()
197
- this.open = up
198
- if (up) void this.refreshCalendarAndRender()
199
+ /** Coordinator opened the dashboard: render fresh from the top. */
200
+ show(): void {
201
+ this.open = true
202
+ this.resetNavigation()
203
+ void this.refreshCalendarAndRender()
199
204
  }
200
205
 
201
- /** Toggle the custom dashboard for an eligible device. */
202
- toggle(): boolean {
203
- if (!this.isEligible()) return false
204
- this.open = !this.open
205
- if (this.open) {
206
- this.resetNavigation()
207
- void this.refreshCalendarAndRender()
208
- }
209
- void Promise.resolve(BluetoothSdk.toggleHeadUp()).catch((error) => {
210
- this.open = !this.open
211
- console.error("CustomDashboard: toggleHeadUp failed", error)
212
- })
213
- return true
206
+ /** Coordinator closed the dashboard. */
207
+ hide(): void {
208
+ this.open = false
209
+ this.resetNavigation()
214
210
  }
215
211
 
216
212
  /** Returns true when an open custom dashboard consumed the gesture. */
@@ -224,7 +220,8 @@ class CustomDashboardService {
224
220
  this.render()
225
221
  return true
226
222
  }
227
- return this.toggle()
223
+ this.onRequestClose?.()
224
+ return true
228
225
  }
229
226
  if (this.view === "apps") return this.handleAppMenuGesture(gestureName)
230
227
  return this.handleMainGesture(gestureName)
@@ -288,26 +285,21 @@ class CustomDashboardService {
288
285
  return normalizeDashboardWidgets(useSettingsStore.getState().getSetting(SETTINGS.custom_dashboard_widgets.key))
289
286
  }
290
287
 
291
- private async close(): Promise<void> {
292
- if (!this.open) return
293
- this.open = false
294
- await Promise.resolve(BluetoothSdk.toggleHeadUp()).catch((error) => {
295
- console.error("CustomDashboard: close failed", error)
296
- })
297
- }
298
-
299
288
  private async openNotificationsPanel(): Promise<void> {
300
- await this.close()
289
+ this.onRequestClose?.()
301
290
  await BluetoothSdk.showNativeNotificationPanel().catch((error) => {
302
291
  console.error("CustomDashboard: notification panel failed", error)
303
292
  })
304
293
  }
305
294
 
306
295
  private async toggleApp(packageName: string): Promise<void> {
307
- await this.close()
296
+ this.onRequestClose?.()
308
297
  const app = useAppStatusStore.getState().apps.find((candidate) => candidate.packageName === packageName)
309
298
  if (!app) return
310
299
  if (app.running) {
300
+ // Mirror the "Starting X…" boot message: without this the dashboard just
301
+ // closes and the stop is invisible on the glasses.
302
+ localDisplayManager.showSystemNotice(`Stopping ${app.name}…`, STOP_NOTICE_MS)
311
303
  await useAppStatusStore.getState().stop(app.packageName)
312
304
  } else {
313
305
  await useAppStatusStore.getState().start(app, {skipNavigation: true})
@@ -318,16 +310,8 @@ class CustomDashboardService {
318
310
  return this.widgets().some((widget) => widget.id === "calendar" && widget.enabled)
319
311
  }
320
312
 
321
- private ensureContextualDashboard(): void {
322
- const settings = useSettingsStore.getState()
323
- if (settings.getSetting(SETTINGS.contextual_dashboard.key) !== true) {
324
- // DeviceManager selects its retained dashboard Scene only while this is
325
- // enabled. Custom firmware eligibility makes that dashboard automatic.
326
- void settings.setSetting(SETTINGS.contextual_dashboard.key, true, false)
327
- }
328
- }
329
-
330
313
  private async refreshCalendarAndRender(): Promise<void> {
314
+ if (!this.open) return
331
315
  if (!this.calendarWidgetEnabled()) {
332
316
  this.calendarStatus = "loading"
333
317
  this.calendarEvents = []
@@ -360,6 +344,19 @@ class CustomDashboardService {
360
344
  this.render()
361
345
  }
362
346
 
347
+ /** Underline hugging a builtin-font label. x/rowY are the label box's origin. */
348
+ private labelUnderline(id: string, x: number, rowY: number, label: string): SceneElementInput {
349
+ // Same metrics processScene uses to ellipsize builtin text; the raster
350
+ // insets text by 2px, so the bar starts under the first glyph.
351
+ const textW = new TextMeasurer(displayProcessor.getProfile()).measureText(label)
352
+ return {
353
+ type: "rect",
354
+ id,
355
+ box: {x: x + 2, y: rowY + UNDERLINE_OFFSET_Y, w: textW + 4, h: UNDERLINE_H},
356
+ style: {border: 2},
357
+ }
358
+ }
359
+
363
360
  private canvas(): {width: number; height: number} {
364
361
  const {sceneCanvasWidth, sceneCanvasHeight} = useGlassesStore.getState()
365
362
  if (sceneCanvasWidth && sceneCanvasWidth > 0 && sceneCanvasHeight && sceneCanvasHeight > 0) {
@@ -369,7 +366,9 @@ class CustomDashboardService {
369
366
  }
370
367
 
371
368
  private render(): void {
372
- if (!this.isEligible()) return
369
+ // Only while open: emitting into the single native view while closed
370
+ // would clobber the main content.
371
+ if (!this.open || !this.isEligible()) return
373
372
  const elements = this.view === "apps" ? this.appMenuElements() : this.mainElements()
374
373
  sceneRenderer.emitScene(DASHBOARD_APP_ID, "dashboard", elements)
375
374
  }
@@ -444,24 +443,26 @@ class CustomDashboardService {
444
443
  style: {font: "builtin", breakMode: "strict-word", overflow: "ellipsis"},
445
444
  })
446
445
  } else if (widget.id === "apps") {
447
- const isSelected = selected?.kind === "apps-menu"
448
446
  elements.push({
449
447
  type: "text",
450
448
  id: "apps-button",
451
449
  box: {x: MARGIN_X, y, w: width, h: BUTTON_H},
452
450
  text: "Apps",
453
- style: {font: "builtin", border: isSelected ? 2 : 0, radius: 8},
451
+ style: {font: "builtin"},
454
452
  })
453
+ if (selected?.kind === "apps-menu") elements.push(this.labelUnderline("apps-underline", MARGIN_X, y, "Apps"))
455
454
  y += BUTTON_H + WIDGET_GAP
456
455
  } else if (widget.id === "notifications") {
457
- const isSelected = selected?.kind === "notifications"
458
456
  elements.push({
459
457
  type: "text",
460
458
  id: "notifications",
461
459
  box: {x: MARGIN_X, y, w: width, h: BUTTON_H},
462
460
  text: "Notifications",
463
- style: {font: "builtin", border: isSelected ? 2 : 0, radius: 8},
461
+ style: {font: "builtin"},
464
462
  })
463
+ if (selected?.kind === "notifications") {
464
+ elements.push(this.labelUnderline("notifications-underline", MARGIN_X, y, "Notifications"))
465
+ }
465
466
  y += BUTTON_H + WIDGET_GAP
466
467
  }
467
468
  }
@@ -535,7 +536,7 @@ class CustomDashboardService {
535
536
  return elements
536
537
  }
537
538
 
538
- // Icon grid, paged, row-major. Selection is the bordered cell.
539
+ // Icon grid, paged, row-major. Selection is the underlined cell.
539
540
  const perPage = APPS_PER_PAGE
540
541
  const pageStart = Math.floor(this.appsIndex / perPage) * perPage
541
542
  const page = apps.slice(pageStart, pageStart + perPage)
@@ -569,8 +570,8 @@ class CustomDashboardService {
569
570
  elements.push({
570
571
  type: "rect",
571
572
  id: "apps-selection",
572
- box: {x, y, w: APP_CELL, h: APP_CELL},
573
- style: {border: 2, radius: 6},
573
+ box: {x, y: y + APP_CELL + 2, w: APP_CELL, h: UNDERLINE_H},
574
+ style: {border: 2},
574
575
  })
575
576
  }
576
577
  })
@@ -584,7 +585,7 @@ class CustomDashboardService {
584
585
  type: "text",
585
586
  id: "apps-selected-name",
586
587
  box: {x: MARGIN_X, y: listTop + gridH + 12, w: width, h: LINE_H},
587
- text: `${selectedApp.name}${selectedApp.running ? " ●" : ""}`,
588
+ text: `${selectedApp.name}${selectedApp.running ? " (running)" : ""}`,
588
589
  style: {font: "builtin", breakMode: "strict-word", overflow: "ellipsis"},
589
590
  })
590
591
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Phone-side dashboard switching. Native retains a single display state; this
3
+ * service decides when the dashboard covers the main content (head-up,
4
+ * double-tap) and which renderer draws it: the CFW custom dashboard, or the
5
+ * legacy time/battery wall for stock devices.
6
+ */
7
+ import {useGlassesStore} from "../stores/glasses"
8
+ import {SETTINGS, useSettingsStore} from "../stores/settings"
9
+ import {BgTimer} from "../utils/timers"
10
+ import customDashboardService from "./CustomDashboardService"
11
+ import {isGlassesConnected} from "./GlassesReadiness"
12
+ import localDisplayManager from "./LocalDisplayManager"
13
+
14
+ const LEGACY_CLOCK_REFRESH_MS = 30_000
15
+ const LEGACY_DASHBOARD_PKG = "system.dashboard"
16
+
17
+ class DashboardCoordinator {
18
+ private opened = false
19
+ private renderer: "custom" | "legacy" | null = null
20
+ private legacyTimer: number | null = null
21
+
22
+ start(): void {
23
+ customDashboardService.onRequestClose = () => this.close()
24
+ }
25
+
26
+ stop(): void {
27
+ this.close()
28
+ customDashboardService.onRequestClose = null
29
+ }
30
+
31
+ get isOpen(): boolean {
32
+ return this.opened
33
+ }
34
+
35
+ /** Head position (real or simulated). Opening respects contextual_dashboard. */
36
+ setHeadUp(up: boolean): void {
37
+ if (up === this.opened) return
38
+ if (up) {
39
+ if (useSettingsStore.getState().getSetting(SETTINGS.contextual_dashboard.key) === false) return
40
+ this.open()
41
+ } else {
42
+ this.close()
43
+ }
44
+ }
45
+
46
+ /** Double-tap on a CFW-eligible device. False when not eligible. */
47
+ toggleCustom(): boolean {
48
+ if (!customDashboardService.isEligible()) return false
49
+ if (this.opened) this.close()
50
+ else this.open()
51
+ return true
52
+ }
53
+
54
+ /** Double-tap fallback for devices without a firmware dashboard. */
55
+ toggleLegacy(): void {
56
+ if (this.opened) this.close()
57
+ else this.open()
58
+ }
59
+
60
+ /** True when an open dashboard consumed the gesture. */
61
+ handleGesture(gestureName: string): boolean {
62
+ if (!this.opened) return false
63
+ if (this.renderer === "custom") return customDashboardService.handleGesture(gestureName)
64
+ if (gestureName === "double_tap") {
65
+ this.close()
66
+ return true
67
+ }
68
+ return false
69
+ }
70
+
71
+ /** Show the dashboard over the main content (ignores contextual_dashboard). */
72
+ open(): void {
73
+ if (this.opened) return
74
+ if (!isGlassesConnected(useGlassesStore.getState().connection)) return
75
+ this.opened = true
76
+ localDisplayManager.setDashboardActive(true)
77
+ if (customDashboardService.isEligible()) {
78
+ this.renderer = "custom"
79
+ customDashboardService.show()
80
+ } else {
81
+ this.renderer = "legacy"
82
+ this.renderLegacy()
83
+ this.legacyTimer = BgTimer.setInterval(() => this.renderLegacy(), LEGACY_CLOCK_REFRESH_MS)
84
+ }
85
+ }
86
+
87
+ /** Close and restore the retained main content. */
88
+ close(): void {
89
+ if (!this.opened) return
90
+ this.opened = false
91
+ if (this.legacyTimer !== null) {
92
+ BgTimer.clearInterval(this.legacyTimer)
93
+ this.legacyTimer = null
94
+ }
95
+ if (this.renderer === "custom") customDashboardService.hide()
96
+ this.renderer = null
97
+ localDisplayManager.setDashboardActive(false)
98
+ }
99
+
100
+ /** Parity with the retired native head-up wall: time, date, battery. */
101
+ private renderLegacy(): void {
102
+ if (!this.opened) return
103
+ const now = new Date()
104
+ const time = new Intl.DateTimeFormat(undefined, {
105
+ hour: "2-digit",
106
+ minute: "2-digit",
107
+ hour12: useSettingsStore.getState().getSetting(SETTINGS.twelve_hour_time.key) === true,
108
+ }).format(now)
109
+ const date = `${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}`
110
+ const level = useGlassesStore.getState().batteryLevel
111
+ const batt = typeof level === "number" && level >= 0 ? ` ${level}%` : ""
112
+ localDisplayManager.renderDashboard(LEGACY_DASHBOARD_PKG, {
113
+ view: "dashboard",
114
+ layout: {layoutType: "text_wall", text: `${time} ${date}${batt} Connected`},
115
+ })
116
+ }
117
+ }
118
+
119
+ const dashboardCoordinator = new DashboardCoordinator()
120
+ export default dashboardCoordinator
121
+ export {DashboardCoordinator}
@@ -35,7 +35,7 @@ import {retirePendingSelectionOnPromotion} from "./PairingIdentity"
35
35
  import GlobalEventEmitter from "../utils/GlobalEventEmitter"
36
36
  import {BgTimer} from "../utils/timers"
37
37
  import {asgCameraApi} from "./asg/asgCameraApi"
38
- import customDashboardService from "./CustomDashboardService"
38
+ import dashboardCoordinator from "./DashboardCoordinator"
39
39
  import gestureDotsService from "./GestureDotsService"
40
40
 
41
41
  let subs: Array<{remove: () => void}> = []
@@ -200,7 +200,7 @@ export function startDeviceEventRouter(): void {
200
200
  BluetoothSdk.addListener("touch_event", (event) => {
201
201
  // While the custom dashboard is visible it owns its focus/navigation
202
202
  // gestures; do not leak those into a miniapp running underneath it.
203
- if (customDashboardService.handleGesture(event.gestureName)) return
203
+ if (dashboardCoordinator.handleGesture(event.gestureName)) return
204
204
 
205
205
  // Gesture-dots debug indicator (any device, gated by its setting). It
206
206
  // only paints while the screen is otherwise empty; the gesture still
@@ -227,7 +227,7 @@ export function startDeviceEventRouter(): void {
227
227
  if (miniappExitGesture.tryExitVisibleMiniapp()) return
228
228
 
229
229
  // Eligible G2 custom firmware replaces the native dashboard.
230
- if (customDashboardService.toggle()) return
230
+ if (dashboardCoordinator.toggleCustom()) return
231
231
 
232
232
  const useNativeDashboard = useSettingsStore.getState().getSetting(SETTINGS.use_native_dashboard.key) !== false
233
233
  if (useNativeDashboard) {
@@ -238,9 +238,7 @@ export function startDeviceEventRouter(): void {
238
238
  })
239
239
  }
240
240
  } else {
241
- void Promise.resolve(BluetoothSdk.toggleHeadUp()).catch((err) => {
242
- console.error("DeviceEventRouter: toggleHeadUp failed:", err)
243
- })
241
+ dashboardCoordinator.toggleLegacy()
244
242
  }
245
243
  }
246
244
  }),
@@ -259,7 +257,7 @@ export function startDeviceEventRouter(): void {
259
257
  // Head position — translate native {up:boolean} → SDK {position:"up"|"down"}.
260
258
  subs.push(
261
259
  BluetoothSdk.addListener("head_up", (event) => {
262
- customDashboardService.setHeadUp(event.up)
260
+ dashboardCoordinator.setHeadUp(event.up)
263
261
  localMiniappRuntime.forwardEvent("head_up", {position: event.up ? "up" : "down", timestamp: Date.now()})
264
262
  }),
265
263
  )
@@ -153,6 +153,9 @@ class LocalDisplayManager {
153
153
  private bootingApp: BootingApp | null = null
154
154
  private bootQueue: Map<string, {payload: DisplayPayload; resolve?: DisplayRequestResolver}> = new Map()
155
155
 
156
+ /** While true the dashboard owns the glasses: main frames are retained but not sent. */
157
+ private dashboardActive = false
158
+
156
159
  /** Miniapp whose visible frame is temporarily covered by an OS exit notice. */
157
160
  private exitingApp: {packageName: string; coveredDisplay: ActiveDisplay} | null = null
158
161
 
@@ -419,6 +422,47 @@ class LocalDisplayManager {
419
422
  this.finishMiniappExit(packageName)
420
423
  }
421
424
 
425
+ // ===========================================================================
426
+ // Dashboard switching (called from DashboardCoordinator)
427
+ // ===========================================================================
428
+
429
+ /**
430
+ * Dashboard cover state. On deactivate the retained main frame repaints
431
+ * (scenes replay create-based; legacy re-sends), or the glasses clear.
432
+ */
433
+ public setDashboardActive(active: boolean): void {
434
+ if (this.dashboardActive === active) return
435
+ this.dashboardActive = active
436
+ useDisplayStore.getState().setDashboardActive(active)
437
+ if (active) return
438
+ sceneRenderer.markViewStale("dashboard")
439
+ const current = this.currentDisplay
440
+ if (current && (current.expiresAt === null || this.now() < current.expiresAt)) {
441
+ this.replayCurrent()
442
+ } else {
443
+ this.sendClear()
444
+ }
445
+ }
446
+
447
+ /** Dashboard renderers only; bypasses arbitration and main ownership state. */
448
+ public renderDashboard(packageName: string, payload: DisplayPayload): void {
449
+ if (!this.dashboardActive) return
450
+ if (payload.scene) {
451
+ sceneRenderer.emitScene(packageName, "dashboard", payload.scene)
452
+ return
453
+ }
454
+ if (!payload.layout) return
455
+ const rawEvent = {view: "dashboard", layout: payload.layout}
456
+ let processedEvent: Record<string, unknown>
457
+ try {
458
+ processedEvent = displayProcessor.processDisplayEvent(rawEvent as any)
459
+ } catch (err) {
460
+ console.error(`${LOG_TAG}: DisplayProcessor error, using raw event:`, err)
461
+ processedEvent = rawEvent
462
+ }
463
+ this.pushToNative(processedEvent)
464
+ }
465
+
422
466
  // ===========================================================================
423
467
  // Display request API (called from LocalMiniappRuntime.handleDisplay)
424
468
  // ===========================================================================
@@ -440,10 +484,11 @@ class LocalDisplayManager {
440
484
  return
441
485
  }
442
486
 
443
- // Dashboard view: pass straight through (no throttle/arbitration). Local
444
- // dashboard rendering is currently a stub on the phone anyway.
487
+ // Dashboard view: only visible while the dashboard covers the glasses.
488
+ // Local miniapp dashboard content remains a stub.
445
489
  if (payload.view === "dashboard") {
446
- this.sendNow(packageName, payload, resolve)
490
+ this.renderDashboard(packageName, payload)
491
+ resolve?.({status: "displayed"})
447
492
  return
448
493
  }
449
494
 
@@ -684,6 +729,23 @@ class LocalDisplayManager {
684
729
  processedEvent = rawEvent
685
730
  }
686
731
 
732
+ // While the dashboard covers the glasses, main frames only update the
733
+ // retained state; setDashboardActive(false) replays the latest.
734
+ if (!this.dashboardActive) this.pushToNative(processedEvent)
735
+
736
+ this.currentDisplay = {packageName, processedEvent, expiresAt}
737
+
738
+ this.clearExpiryTimer()
739
+ if (expiresAt !== null) {
740
+ const delay = Math.max(0, expiresAt - this.now())
741
+ this.expiryTimerId = BgTimer.setTimeout(() => {
742
+ this.expiryTimerId = null
743
+ this.handleExpiry(packageName)
744
+ }, delay)
745
+ }
746
+ }
747
+
748
+ private pushToNative(processedEvent: Record<string, unknown>): void {
687
749
  try {
688
750
  // The display output path is a direct btsdk call now (was a host sendDisplayEvent
689
751
  // hook) so a bare OEM renders to the glasses without wiring it.
@@ -696,17 +758,6 @@ class LocalDisplayManager {
696
758
  } catch (err) {
697
759
  console.error(`${LOG_TAG}: native display failed:`, err)
698
760
  }
699
-
700
- this.currentDisplay = {packageName, processedEvent, expiresAt}
701
-
702
- this.clearExpiryTimer()
703
- if (expiresAt !== null) {
704
- const delay = Math.max(0, expiresAt - this.now())
705
- this.expiryTimerId = BgTimer.setTimeout(() => {
706
- this.expiryTimerId = null
707
- this.handleExpiry(packageName)
708
- }, delay)
709
- }
710
761
  }
711
762
 
712
763
  private handleExpiry(packageName: string): void {
@@ -913,6 +964,7 @@ class LocalDisplayManager {
913
964
  this.currentDisplay = null
914
965
  this.backgroundLock = null
915
966
  this.exitingApp = null
967
+ this.dashboardActive = false
916
968
  this.bootQueue.clear()
917
969
  this.now = () => Date.now()
918
970
  this.unsubscribeGlassesStatus?.()