@glassly/engine 0.1.0-dev.1 → 0.1.0-dev.27

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 (277) hide show
  1. package/README.md +53 -19
  2. package/build/bluetooth-sdk/debug.d.ts +2 -0
  3. package/build/bluetooth-sdk/debug.d.ts.map +1 -0
  4. package/build/bluetooth-sdk/debug.js +2 -0
  5. package/build/bluetooth-sdk/debug.js.map +1 -0
  6. package/build/bluetooth-sdk/index.d.ts +3 -0
  7. package/build/bluetooth-sdk/index.d.ts.map +1 -0
  8. package/build/bluetooth-sdk/index.js +3 -0
  9. package/build/bluetooth-sdk/index.js.map +1 -0
  10. package/build/bluetooth-sdk/ota-transport.d.ts +2 -0
  11. package/build/bluetooth-sdk/ota-transport.d.ts.map +1 -0
  12. package/build/bluetooth-sdk/ota-transport.js +2 -0
  13. package/build/bluetooth-sdk/ota-transport.js.map +1 -0
  14. package/build/bluetooth-sdk/photo-receiver.d.ts +3 -0
  15. package/build/bluetooth-sdk/photo-receiver.d.ts.map +1 -0
  16. package/build/bluetooth-sdk/photo-receiver.js +3 -0
  17. package/build/bluetooth-sdk/photo-receiver.js.map +1 -0
  18. package/build/bluetooth-sdk/react.d.ts +2 -0
  19. package/build/bluetooth-sdk/react.d.ts.map +1 -0
  20. package/build/bluetooth-sdk/react.js +2 -0
  21. package/build/bluetooth-sdk/react.js.map +1 -0
  22. package/build/bluetooth-sdk/types.d.ts +2 -0
  23. package/build/bluetooth-sdk/types.d.ts.map +1 -0
  24. package/build/bluetooth-sdk/types.js +2 -0
  25. package/build/bluetooth-sdk/types.js.map +1 -0
  26. package/build/engine.d.ts +23 -16
  27. package/build/engine.d.ts.map +1 -1
  28. package/build/engine.js +9 -3
  29. package/build/engine.js.map +1 -1
  30. package/build/facades/dev.d.ts +1 -1
  31. package/build/facades/display.d.ts +1 -1
  32. package/build/facades/display.d.ts.map +1 -1
  33. package/build/facades/display.js +1 -1
  34. package/build/facades/display.js.map +1 -1
  35. package/build/facades/glasses.d.ts +5 -13
  36. package/build/facades/glasses.d.ts.map +1 -1
  37. package/build/facades/glasses.js +5 -3
  38. package/build/facades/glasses.js.map +1 -1
  39. package/build/facades/ota.d.ts +19 -21
  40. package/build/facades/ota.d.ts.map +1 -1
  41. package/build/facades/ota.js +23 -9
  42. package/build/facades/ota.js.map +1 -1
  43. package/build/facades/pairing.d.ts +11 -5
  44. package/build/facades/pairing.d.ts.map +1 -1
  45. package/build/facades/pairing.js +93 -17
  46. package/build/facades/pairing.js.map +1 -1
  47. package/build/facades/speakers.d.ts +1 -1
  48. package/build/facades/speakers.d.ts.map +1 -1
  49. package/build/facades/speakers.js.map +1 -1
  50. package/build/facades/speech.d.ts +7 -6
  51. package/build/facades/speech.d.ts.map +1 -1
  52. package/build/facades/speech.js +1 -1
  53. package/build/facades/speech.js.map +1 -1
  54. package/build/generated/releaseMetadata.d.ts +12 -0
  55. package/build/generated/releaseMetadata.d.ts.map +1 -0
  56. package/build/generated/releaseMetadata.js +10 -0
  57. package/build/generated/releaseMetadata.js.map +1 -0
  58. package/build/index.d.ts +7 -4
  59. package/build/index.d.ts.map +1 -1
  60. package/build/index.js +5 -3
  61. package/build/index.js.map +1 -1
  62. package/build/react/MentraLiveOtaFlow.d.ts +39 -0
  63. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -0
  64. package/build/react/MentraLiveOtaFlow.js +200 -0
  65. package/build/react/MentraLiveOtaFlow.js.map +1 -0
  66. package/build/react/index.d.ts +3 -0
  67. package/build/react/index.d.ts.map +1 -0
  68. package/build/react/index.js +3 -0
  69. package/build/react/index.js.map +1 -0
  70. package/build/react/useEngineSnapshot.d.ts +2 -0
  71. package/build/react/useEngineSnapshot.d.ts.map +1 -0
  72. package/build/react/useEngineSnapshot.js +14 -0
  73. package/build/react/useEngineSnapshot.js.map +1 -0
  74. package/build/react/useMentraLiveOta.d.ts +80 -0
  75. package/build/react/useMentraLiveOta.d.ts.map +1 -0
  76. package/build/react/useMentraLiveOta.js +550 -0
  77. package/build/react/useMentraLiveOta.js.map +1 -0
  78. package/build/services/AppRegistry.d.ts +8 -0
  79. package/build/services/AppRegistry.d.ts.map +1 -1
  80. package/build/services/AppRegistry.js +15 -0
  81. package/build/services/AppRegistry.js.map +1 -1
  82. package/build/services/BlobStore.d.ts +2 -0
  83. package/build/services/BlobStore.d.ts.map +1 -1
  84. package/build/services/BlobStore.js +63 -10
  85. package/build/services/BlobStore.js.map +1 -1
  86. package/build/services/CustomDashboardModel.d.ts +48 -0
  87. package/build/services/CustomDashboardModel.d.ts.map +1 -0
  88. package/build/services/CustomDashboardModel.js +73 -0
  89. package/build/services/CustomDashboardModel.js.map +1 -0
  90. package/build/services/CustomDashboardService.d.ts +61 -0
  91. package/build/services/CustomDashboardService.d.ts.map +1 -0
  92. package/build/services/CustomDashboardService.js +552 -0
  93. package/build/services/CustomDashboardService.js.map +1 -0
  94. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  95. package/build/services/DeviceEventRouter.js +44 -3
  96. package/build/services/DeviceEventRouter.js.map +1 -1
  97. package/build/services/GestureDotsService.d.ts +35 -0
  98. package/build/services/GestureDotsService.d.ts.map +1 -0
  99. package/build/services/GestureDotsService.js +149 -0
  100. package/build/services/GestureDotsService.js.map +1 -0
  101. package/build/services/GlassesStatusProjection.d.ts +15 -1
  102. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  103. package/build/services/GlassesStatusProjection.js +18 -11
  104. package/build/services/GlassesStatusProjection.js.map +1 -1
  105. package/build/services/HotspotOtaTransport.d.ts.map +1 -1
  106. package/build/services/HotspotOtaTransport.js +8 -7
  107. package/build/services/HotspotOtaTransport.js.map +1 -1
  108. package/build/services/LocalDisplayManager.d.ts +43 -1
  109. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  110. package/build/services/LocalDisplayManager.js +128 -2
  111. package/build/services/LocalDisplayManager.js.map +1 -1
  112. package/build/services/LocalMiniappRuntime.d.ts +13 -0
  113. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  114. package/build/services/LocalMiniappRuntime.js +32 -0
  115. package/build/services/LocalMiniappRuntime.js.map +1 -1
  116. package/build/services/MiniappExitGesture.d.ts +33 -0
  117. package/build/services/MiniappExitGesture.d.ts.map +1 -0
  118. package/build/services/MiniappExitGesture.js +54 -0
  119. package/build/services/MiniappExitGesture.js.map +1 -0
  120. package/build/services/OtaAutoChain.d.ts +41 -0
  121. package/build/services/OtaAutoChain.d.ts.map +1 -0
  122. package/build/services/OtaAutoChain.js +91 -0
  123. package/build/services/OtaAutoChain.js.map +1 -0
  124. package/build/services/OtaErrorMapping.d.ts +18 -0
  125. package/build/services/OtaErrorMapping.d.ts.map +1 -0
  126. package/build/services/OtaErrorMapping.js +63 -0
  127. package/build/services/OtaErrorMapping.js.map +1 -0
  128. package/build/services/OtaInstallCoordinator.d.ts +24 -3
  129. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  130. package/build/services/OtaInstallCoordinator.js +17 -12
  131. package/build/services/OtaInstallCoordinator.js.map +1 -1
  132. package/build/services/OtaService.d.ts.map +1 -1
  133. package/build/services/OtaService.js +1 -1
  134. package/build/services/OtaService.js.map +1 -1
  135. package/build/services/OtaUpdateCheckService.d.ts +4 -1
  136. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  137. package/build/services/OtaUpdateCheckService.js +28 -16
  138. package/build/services/OtaUpdateCheckService.js.map +1 -1
  139. package/build/services/asg/localNetworkTransport.d.ts.map +1 -1
  140. package/build/services/asg/localNetworkTransport.js +13 -12
  141. package/build/services/asg/localNetworkTransport.js.map +1 -1
  142. package/build/services/gestureClaims.d.ts +18 -0
  143. package/build/services/gestureClaims.d.ts.map +1 -0
  144. package/build/services/gestureClaims.js +24 -0
  145. package/build/services/gestureClaims.js.map +1 -0
  146. package/build/services/gestureDotGlyphs.d.ts +8 -0
  147. package/build/services/gestureDotGlyphs.d.ts.map +1 -0
  148. package/build/services/gestureDotGlyphs.js +12 -0
  149. package/build/services/gestureDotGlyphs.js.map +1 -0
  150. package/build/services/glassesClockSync.d.ts.map +1 -1
  151. package/build/services/glassesClockSync.js +5 -1
  152. package/build/services/glassesClockSync.js.map +1 -1
  153. package/build/services/otaDisplayState.d.ts +1 -1
  154. package/build/services/otaDisplayState.d.ts.map +1 -1
  155. package/build/services/otaDisplayState.js.map +1 -1
  156. package/build/services/otaInstallPolicy.d.ts +1 -1
  157. package/build/services/otaInstallPolicy.d.ts.map +1 -1
  158. package/build/services/otaInstallPolicy.js.map +1 -1
  159. package/build/services/otaLegacyMapping.d.ts +1 -1
  160. package/build/services/otaLegacyMapping.d.ts.map +1 -1
  161. package/build/services/otaLegacyMapping.js.map +1 -1
  162. package/build/services/otaManifestPolicy.d.ts +14 -0
  163. package/build/services/otaManifestPolicy.d.ts.map +1 -0
  164. package/build/services/otaManifestPolicy.js +20 -0
  165. package/build/services/otaManifestPolicy.js.map +1 -0
  166. package/build/services/otaManifestUrl.d.ts +2 -1
  167. package/build/services/otaManifestUrl.d.ts.map +1 -1
  168. package/build/services/otaManifestUrl.js +24 -32
  169. package/build/services/otaManifestUrl.js.map +1 -1
  170. package/build/services/otaReleaseVersion.d.ts +10 -0
  171. package/build/services/otaReleaseVersion.d.ts.map +1 -0
  172. package/build/services/otaReleaseVersion.js +14 -0
  173. package/build/services/otaReleaseVersion.js.map +1 -0
  174. package/build/stores/appVisibility.d.ts +3 -0
  175. package/build/stores/appVisibility.d.ts.map +1 -0
  176. package/build/stores/appVisibility.js +3 -0
  177. package/build/stores/appVisibility.js.map +1 -0
  178. package/build/stores/apps.d.ts.map +1 -1
  179. package/build/stores/apps.js +7 -1
  180. package/build/stores/apps.js.map +1 -1
  181. package/build/stores/core.d.ts +3 -3
  182. package/build/stores/core.d.ts.map +1 -1
  183. package/build/stores/core.js.map +1 -1
  184. package/build/stores/glasses.d.ts +10 -4
  185. package/build/stores/glasses.d.ts.map +1 -1
  186. package/build/stores/glasses.js.map +1 -1
  187. package/build/stores/settings.d.ts.map +1 -1
  188. package/build/stores/settings.js +87 -3
  189. package/build/stores/settings.js.map +1 -1
  190. package/build/types/applet.d.ts +2 -0
  191. package/build/types/applet.d.ts.map +1 -1
  192. package/build/types/applet.js +0 -3
  193. package/build/types/applet.js.map +1 -1
  194. package/build/types/enums.d.ts +1 -5
  195. package/build/types/enums.d.ts.map +1 -1
  196. package/build/types/enums.js +1 -8
  197. package/build/types/enums.js.map +1 -1
  198. package/build/types/hardware.d.ts +1 -1
  199. package/build/types/hardware.d.ts.map +1 -1
  200. package/build/types/hardware.js.map +1 -1
  201. package/build/types/index.d.ts +2 -6
  202. package/build/types/index.d.ts.map +1 -1
  203. package/build/types/index.js +2 -6
  204. package/build/types/index.js.map +1 -1
  205. package/build/utils/display/scene/process.d.ts.map +1 -1
  206. package/build/utils/display/scene/process.js +4 -3
  207. package/build/utils/display/scene/process.js.map +1 -1
  208. package/build/utils/display/scene/types.d.ts +6 -2
  209. package/build/utils/display/scene/types.d.ts.map +1 -1
  210. package/build/utils/display/scene/types.js.map +1 -1
  211. package/package.json +65 -33
  212. package/src/bluetooth-sdk/debug.ts +1 -0
  213. package/src/bluetooth-sdk/index.ts +2 -0
  214. package/src/bluetooth-sdk/ota-transport.ts +1 -0
  215. package/src/bluetooth-sdk/photo-receiver.ts +2 -0
  216. package/src/bluetooth-sdk/react.ts +1 -0
  217. package/src/bluetooth-sdk/types.ts +1 -0
  218. package/src/engine.ts +9 -3
  219. package/src/facades/display.ts +1 -1
  220. package/src/facades/glasses.ts +6 -4
  221. package/src/facades/ota.ts +33 -10
  222. package/src/facades/pairing.ts +103 -23
  223. package/src/facades/speakers.ts +1 -1
  224. package/src/facades/speech.ts +21 -7
  225. package/src/generated/releaseMetadata.ts +20 -0
  226. package/src/index.ts +29 -4
  227. package/src/react/MentraLiveOtaFlow.tsx +569 -0
  228. package/src/react/index.ts +21 -0
  229. package/src/react/useEngineSnapshot.ts +16 -0
  230. package/src/react/useMentraLiveOta.ts +707 -0
  231. package/src/services/AppRegistry.ts +16 -0
  232. package/src/services/BlobStore.ts +57 -10
  233. package/src/services/CustomDashboardModel.ts +83 -0
  234. package/src/services/CustomDashboardService.ts +575 -0
  235. package/src/services/DeviceEventRouter.ts +47 -5
  236. package/src/services/GestureDotsService.ts +147 -0
  237. package/src/services/GlassesStatusProjection.ts +20 -11
  238. package/src/services/HotspotOtaTransport.ts +8 -7
  239. package/src/services/LocalDisplayManager.ts +130 -2
  240. package/src/services/LocalMiniappRuntime.ts +37 -0
  241. package/src/services/MiniappExitGesture.ts +76 -0
  242. package/src/services/OtaAutoChain.ts +130 -0
  243. package/src/services/OtaErrorMapping.ts +80 -0
  244. package/src/services/OtaInstallCoordinator.ts +19 -13
  245. package/src/services/OtaService.ts +1 -2
  246. package/src/services/OtaUpdateCheckService.ts +33 -19
  247. package/src/services/asg/localNetworkTransport.ts +20 -19
  248. package/src/services/gestureClaims.ts +28 -0
  249. package/src/services/gestureDotGlyphs.ts +14 -0
  250. package/src/services/glassesClockSync.ts +5 -1
  251. package/src/services/otaDisplayState.ts +1 -1
  252. package/src/services/otaInstallPolicy.ts +1 -1
  253. package/src/services/otaLegacyMapping.ts +1 -1
  254. package/src/services/otaManifestPolicy.ts +43 -0
  255. package/src/services/otaManifestUrl.ts +27 -36
  256. package/src/services/otaReleaseVersion.ts +22 -0
  257. package/src/stores/appVisibility.ts +3 -0
  258. package/src/stores/apps.ts +7 -1
  259. package/src/stores/core.ts +4 -4
  260. package/src/stores/glasses.ts +13 -6
  261. package/src/stores/settings.ts +87 -3
  262. package/src/types/applet.ts +2 -0
  263. package/src/types/enums.ts +1 -9
  264. package/src/types/hardware.ts +1 -1
  265. package/src/types/index.ts +2 -6
  266. package/src/utils/display/scene/process.ts +4 -3
  267. package/src/utils/display/scene/types.ts +0 -0
  268. package/build/devtools.d.ts +0 -11
  269. package/build/devtools.d.ts.map +0 -1
  270. package/build/devtools.js +0 -13
  271. package/build/devtools.js.map +0 -1
  272. package/build/internal.d.ts +0 -67
  273. package/build/internal.d.ts.map +0 -1
  274. package/build/internal.js +0 -90
  275. package/build/internal.js.map +0 -1
  276. package/src/devtools.ts +0 -13
  277. package/src/internal.ts +0 -155
@@ -0,0 +1,575 @@
1
+ /**
2
+ * Scene API dashboard for G2 custom firmware.
3
+ *
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.
8
+ *
9
+ * The dashboard is composed of user-orderable widgets (clock, calendar, apps
10
+ * button, notifications button) stacked vertically on the CFW scene canvas.
11
+ * The apps button switches to a second, dashboard-rendered app menu view.
12
+ */
13
+ import {File} from "expo-file-system"
14
+
15
+ import {BluetoothSdk} from "@glassly/bluetooth-sdk"
16
+ import type {CalendarEvent} from "@glassly/miniapp"
17
+
18
+ import {useAppStatusStore} from "../stores/apps"
19
+ import {useGlassesStore} from "../stores/glasses"
20
+ import {SETTINGS, useSettingsStore} from "../stores/settings"
21
+ import type {ClientApp} from "../types/applet"
22
+ import type {SceneElementInput} from "../utils/display/scene"
23
+ import {BgTimer} from "../utils/timers"
24
+ import {
25
+ appMenuActions,
26
+ dashboardActions,
27
+ formatDashboardClock,
28
+ isCustomDashboardEligible,
29
+ normalizeDashboardWidgets,
30
+ orderedDashboardApps,
31
+ type CustomDashboardClockStyle,
32
+ type DashboardWidgetSetting,
33
+ type MenuAppSetting,
34
+ } from "./CustomDashboardModel"
35
+ import {isGlassesConnected} from "./GlassesReadiness"
36
+ import {listPhoneCalendarEvents} from "./PhoneCalendarService"
37
+ import sceneRenderer from "./SceneRenderer"
38
+
39
+ const DASHBOARD_APP_ID = "system.custom-dashboard"
40
+ const CLOCK_REFRESH_MS = 30_000
41
+ const CALENDAR_REFRESH_MS = 5 * 60_000
42
+
43
+ // CFW blits the stock compositor's 576×288 canvas; used when the firmware has
44
+ // not reported scene capabilities yet.
45
+ const FALLBACK_CANVAS = {width: 576, height: 288}
46
+ const MARGIN_X = 16
47
+ const MARGIN_TOP = 10
48
+ const WIDGET_GAP = 10
49
+ /** Row height for the glasses' builtin 20px font engine. */
50
+ const LINE_H = 24
51
+ const BUTTON_H = 34
52
+ /** App-menu icon grid: 4 per page (G2 firmware image-container pool). */
53
+ const APPS_PER_PAGE = 4
54
+ const APP_CELL = 72
55
+ const APP_CELL_GAP = 16
56
+ const APP_ICON_INSET = 4
57
+ const APP_ICON = APP_CELL - APP_ICON_INSET * 2
58
+
59
+ function eventLine(event: CalendarEvent): string {
60
+ const when = event.allDay
61
+ ? "All day"
62
+ : new Intl.DateTimeFormat(undefined, {hour: "numeric", minute: "2-digit"}).format(new Date(event.startsAt))
63
+ return `${when} ${event.title}`
64
+ }
65
+
66
+ class CustomDashboardService {
67
+ private started = false
68
+ private open = false
69
+ private view: "main" | "apps" = "main"
70
+ private mainIndex = 0
71
+ private appsIndex = 0
72
+ private calendarAvailable = false
73
+ /** packageName → base64 icon bytes, or null when the icon can't be loaded (monogram fallback). */
74
+ private iconCache = new Map<string, string | null>()
75
+ private iconLoads = new Set<string>()
76
+ private calendarEvents: CalendarEvent[] = []
77
+ private unsubs: Array<() => void> = []
78
+ private clockTimer: number | null = null
79
+ private calendarTimer: number | null = null
80
+ /** Last observed eligibility, for transition detection. */
81
+ private eligible = false
82
+
83
+ start(): void {
84
+ if (this.started) return
85
+ this.started = true
86
+ this.unsubs.push(
87
+ useGlassesStore.subscribe(
88
+ (state) => `${state.connection.state}:${state.deviceModel}:${state.firmwareVersion}`,
89
+ () => {
90
+ if (!this.syncEligibility()) return
91
+ void this.refreshCalendarAndRender()
92
+ },
93
+ ),
94
+ // Selector: only dashboard-relevant settings retrigger a refresh. The
95
+ // whole-store form fired a calendar query on every settings write.
96
+ useSettingsStore.subscribe(
97
+ (state) =>
98
+ JSON.stringify([
99
+ state.getSetting(SETTINGS.custom_dashboard_enabled.key),
100
+ state.getSetting(SETTINGS.custom_dashboard_widgets.key),
101
+ state.getSetting(SETTINGS.custom_dashboard_clock_style.key),
102
+ state.getSetting(SETTINGS.custom_dashboard_clock_show_date.key),
103
+ state.getSetting(SETTINGS.twelve_hour_time.key),
104
+ state.getSetting(SETTINGS.custom_dashboard_calendar_event_count.key),
105
+ state.getSetting(SETTINGS.custom_dashboard_calendar_days_ahead.key),
106
+ state.getSetting(SETTINGS.menu_apps.key),
107
+ ]),
108
+ () => {
109
+ if (!this.syncEligibility()) return
110
+ void this.refreshCalendarAndRender()
111
+ },
112
+ ),
113
+ useAppStatusStore.subscribe(() => {
114
+ if (this.isEligible()) this.render()
115
+ }),
116
+ )
117
+ this.clockTimer = BgTimer.setInterval(() => {
118
+ if (this.isEligible()) this.render()
119
+ }, CLOCK_REFRESH_MS)
120
+ this.calendarTimer = BgTimer.setInterval(() => {
121
+ if (this.isEligible()) void this.refreshCalendarAndRender()
122
+ }, CALENDAR_REFRESH_MS)
123
+ this.eligible = this.isEligible()
124
+ if (this.eligible) {
125
+ this.ensureContextualDashboard()
126
+ void this.refreshCalendarAndRender()
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Track eligible⇄ineligible transitions from any trigger (setting toggle,
132
+ * connect/disconnect, firmware change). Entering enables the contextual
133
+ * dashboard once — not on every settings write, so a user's manual
134
+ * contextual-dashboard choice is no longer ratcheted back. Leaving closes
135
+ * the dashboard, blanks the glasses view, and drops the retained scene so a
136
+ * stale frame can't be replayed after the feature was turned off.
137
+ */
138
+ private syncEligibility(): boolean {
139
+ const eligible = this.isEligible()
140
+ if (eligible === this.eligible) return eligible
141
+ this.eligible = eligible
142
+ if (eligible) {
143
+ this.ensureContextualDashboard()
144
+ } else {
145
+ this.open = false
146
+ this.resetNavigation()
147
+ sceneRenderer.emitScene(DASHBOARD_APP_ID, "dashboard", [])
148
+ sceneRenderer.clearApp(DASHBOARD_APP_ID)
149
+ }
150
+ return eligible
151
+ }
152
+
153
+ stop(): void {
154
+ for (const unsub of this.unsubs) unsub()
155
+ this.unsubs = []
156
+ if (this.clockTimer !== null) BgTimer.clearInterval(this.clockTimer)
157
+ if (this.calendarTimer !== null) BgTimer.clearInterval(this.calendarTimer)
158
+ this.clockTimer = null
159
+ this.calendarTimer = null
160
+ this.open = false
161
+ this.eligible = false
162
+ this.resetNavigation()
163
+ this.started = false
164
+ sceneRenderer.clearApp(DASHBOARD_APP_ID)
165
+ }
166
+
167
+ isEligible(): boolean {
168
+ if (useSettingsStore.getState().getSetting(SETTINGS.custom_dashboard_enabled.key) === false) return false
169
+ const state = useGlassesStore.getState()
170
+ return (
171
+ isGlassesConnected(state.connection) &&
172
+ isCustomDashboardEligible({
173
+ model: state.deviceModel,
174
+ firmwareVersion: state.firmwareVersion,
175
+ })
176
+ )
177
+ }
178
+
179
+ get isOpen(): boolean {
180
+ return this.open
181
+ }
182
+
183
+ setHeadUp(up: boolean): void {
184
+ if (!this.isEligible()) {
185
+ this.open = false
186
+ return
187
+ }
188
+ if (up && !this.open) this.resetNavigation()
189
+ this.open = up
190
+ if (up) void this.refreshCalendarAndRender()
191
+ }
192
+
193
+ /** Toggle the custom dashboard for an eligible device. */
194
+ toggle(): boolean {
195
+ if (!this.isEligible()) return false
196
+ this.open = !this.open
197
+ if (this.open) {
198
+ this.resetNavigation()
199
+ void this.refreshCalendarAndRender()
200
+ }
201
+ void Promise.resolve(BluetoothSdk.toggleHeadUp()).catch((error) => {
202
+ this.open = !this.open
203
+ console.error("CustomDashboard: toggleHeadUp failed", error)
204
+ })
205
+ return true
206
+ }
207
+
208
+ /** Returns true when an open custom dashboard consumed the gesture. */
209
+ handleGesture(gestureName: string): boolean {
210
+ if (!this.open || !this.isEligible()) return false
211
+ if (gestureName === "double_tap") {
212
+ // In the app menu a double tap backs out to the top of the dashboard
213
+ // (there is no Back row); on the main view it closes the dashboard.
214
+ if (this.view === "apps") {
215
+ this.resetNavigation()
216
+ this.render()
217
+ return true
218
+ }
219
+ return this.toggle()
220
+ }
221
+ if (this.view === "apps") return this.handleAppMenuGesture(gestureName)
222
+ return this.handleMainGesture(gestureName)
223
+ }
224
+
225
+ private resetNavigation(): void {
226
+ this.view = "main"
227
+ this.mainIndex = 0
228
+ this.appsIndex = 0
229
+ }
230
+
231
+ private handleMainGesture(gestureName: string): boolean {
232
+ const actions = dashboardActions(this.widgets())
233
+ if (this.mainIndex >= actions.length) this.mainIndex = Math.max(0, actions.length - 1)
234
+ if (gestureName === "swipe_down" || gestureName === "swipe_up") {
235
+ if (actions.length === 0) return true
236
+ const delta = gestureName === "swipe_down" ? 1 : -1
237
+ this.mainIndex = (this.mainIndex + delta + actions.length) % actions.length
238
+ this.render()
239
+ return true
240
+ }
241
+ if (gestureName === "single_tap") {
242
+ const action = actions[this.mainIndex]
243
+ if (action?.kind === "apps-menu") {
244
+ this.view = "apps"
245
+ this.appsIndex = 0
246
+ this.render()
247
+ } else if (action?.kind === "notifications") {
248
+ void this.openNotificationsPanel()
249
+ }
250
+ return true
251
+ }
252
+ return false
253
+ }
254
+
255
+ private handleAppMenuGesture(gestureName: string): boolean {
256
+ const rows = appMenuActions(this.menuApps())
257
+ if (this.appsIndex >= rows.length) this.appsIndex = Math.max(0, rows.length - 1)
258
+ if (gestureName === "swipe_down" || gestureName === "swipe_up") {
259
+ const delta = gestureName === "swipe_down" ? 1 : -1
260
+ this.appsIndex = (this.appsIndex + delta + rows.length) % rows.length
261
+ this.render()
262
+ return true
263
+ }
264
+ if (gestureName === "single_tap") {
265
+ const row = rows[this.appsIndex]
266
+ if (row?.kind === "app") {
267
+ void this.toggleApp(row.packageName)
268
+ }
269
+ return true
270
+ }
271
+ return false
272
+ }
273
+
274
+ private menuApps(): ClientApp[] {
275
+ const menu = useSettingsStore.getState().getSetting(SETTINGS.menu_apps.key) as MenuAppSetting[] | null
276
+ return orderedDashboardApps(menu, useAppStatusStore.getState().apps)
277
+ }
278
+
279
+ private widgets(): DashboardWidgetSetting[] {
280
+ return normalizeDashboardWidgets(useSettingsStore.getState().getSetting(SETTINGS.custom_dashboard_widgets.key))
281
+ }
282
+
283
+ private async close(): Promise<void> {
284
+ if (!this.open) return
285
+ this.open = false
286
+ await Promise.resolve(BluetoothSdk.toggleHeadUp()).catch((error) => {
287
+ console.error("CustomDashboard: close failed", error)
288
+ })
289
+ }
290
+
291
+ private async openNotificationsPanel(): Promise<void> {
292
+ await this.close()
293
+ await BluetoothSdk.showNativeNotificationPanel().catch((error) => {
294
+ console.error("CustomDashboard: notification panel failed", error)
295
+ })
296
+ }
297
+
298
+ private async toggleApp(packageName: string): Promise<void> {
299
+ await this.close()
300
+ const app = useAppStatusStore.getState().apps.find((candidate) => candidate.packageName === packageName)
301
+ if (!app) return
302
+ if (app.running) {
303
+ await useAppStatusStore.getState().stop(app.packageName)
304
+ } else {
305
+ await useAppStatusStore.getState().start(app, {skipNavigation: true})
306
+ }
307
+ }
308
+
309
+ private calendarWidgetEnabled(): boolean {
310
+ return this.widgets().some((widget) => widget.id === "calendar" && widget.enabled)
311
+ }
312
+
313
+ private ensureContextualDashboard(): void {
314
+ const settings = useSettingsStore.getState()
315
+ if (settings.getSetting(SETTINGS.contextual_dashboard.key) !== true) {
316
+ // DeviceManager selects its retained dashboard Scene only while this is
317
+ // enabled. Custom firmware eligibility makes that dashboard automatic.
318
+ void settings.setSetting(SETTINGS.contextual_dashboard.key, true, false)
319
+ }
320
+ }
321
+
322
+ private async refreshCalendarAndRender(): Promise<void> {
323
+ if (!this.calendarWidgetEnabled()) {
324
+ this.calendarAvailable = false
325
+ this.calendarEvents = []
326
+ this.render()
327
+ return
328
+ }
329
+ const settings = useSettingsStore.getState()
330
+ const count = Number(settings.getSetting(SETTINGS.custom_dashboard_calendar_event_count.key) ?? 2)
331
+ const days = Number(settings.getSetting(SETTINGS.custom_dashboard_calendar_days_ahead.key) ?? 3)
332
+ const startsAt = new Date()
333
+ const endsAt = new Date(startsAt.getTime() + Math.max(1, days) * 24 * 60 * 60 * 1000)
334
+ try {
335
+ const result = await listPhoneCalendarEvents({startsAt, endsAt, limit: Math.max(1, Math.min(3, count))})
336
+ this.calendarAvailable = true
337
+ this.calendarEvents = result.events
338
+ } catch {
339
+ // Permission is requested from the Custom Dashboard settings screen;
340
+ // without access the widget silently stays off the glasses.
341
+ this.calendarAvailable = false
342
+ this.calendarEvents = []
343
+ }
344
+ this.render()
345
+ }
346
+
347
+ private canvas(): {width: number; height: number} {
348
+ const {sceneCanvasWidth, sceneCanvasHeight} = useGlassesStore.getState()
349
+ if (sceneCanvasWidth && sceneCanvasWidth > 0 && sceneCanvasHeight && sceneCanvasHeight > 0) {
350
+ return {width: sceneCanvasWidth, height: sceneCanvasHeight}
351
+ }
352
+ return FALLBACK_CANVAS
353
+ }
354
+
355
+ private render(): void {
356
+ if (!this.isEligible()) return
357
+ const elements = this.view === "apps" ? this.appMenuElements() : this.mainElements()
358
+ sceneRenderer.emitScene(DASHBOARD_APP_ID, "dashboard", elements)
359
+ }
360
+
361
+ private mainElements(): SceneElementInput[] {
362
+ const canvas = this.canvas()
363
+ const settings = useSettingsStore.getState()
364
+ const widgets = this.widgets().filter((widget) => widget.enabled)
365
+ const actions = dashboardActions(widgets)
366
+ if (this.mainIndex >= actions.length) this.mainIndex = Math.max(0, actions.length - 1)
367
+ const selected = actions[this.mainIndex]
368
+
369
+ const clockStyle =
370
+ (settings.getSetting(SETTINGS.custom_dashboard_clock_style.key) as CustomDashboardClockStyle) === "large"
371
+ ? "large"
372
+ : "compact"
373
+ const calendarEnabled = widgets.some((widget) => widget.id === "calendar")
374
+ // Calendar lives in the right half; everything else stacks in the left
375
+ // half (full width when the calendar widget is off).
376
+ const halfW = Math.floor(canvas.width / 2) - MARGIN_X - Math.floor(WIDGET_GAP / 2)
377
+ const width = calendarEnabled ? halfW : canvas.width - MARGIN_X * 2
378
+ const rightX = Math.floor(canvas.width / 2) + Math.floor(WIDGET_GAP / 2)
379
+
380
+ const elements: SceneElementInput[] = []
381
+ let y = MARGIN_TOP
382
+ for (const widget of widgets) {
383
+ if (widget.id === "clock") {
384
+ const fontSize = clockStyle === "large" ? 48 : 32
385
+ const clock = formatDashboardClock(new Date(), {
386
+ twelveHour: settings.getSetting(SETTINGS.twelve_hour_time.key) === true,
387
+ showDate: settings.getSetting(SETTINGS.custom_dashboard_clock_show_date.key) !== false,
388
+ })
389
+ elements.push({
390
+ type: "text",
391
+ id: "clock",
392
+ box: {x: MARGIN_X, y, w: width, h: fontSize + 6},
393
+ text: clock.time,
394
+ style: {font: "mono", fontSize, breakMode: "strict-word"},
395
+ })
396
+ y += fontSize + 6
397
+ if (clock.date) {
398
+ elements.push({
399
+ type: "text",
400
+ id: "clock-date",
401
+ box: {x: MARGIN_X, y, w: width, h: LINE_H},
402
+ text: clock.date,
403
+ style: {font: "builtin", breakMode: "strict-word"},
404
+ })
405
+ y += LINE_H
406
+ }
407
+ y += WIDGET_GAP
408
+ } else if (widget.id === "calendar") {
409
+ // Right half, top-aligned, independent of the left stack. A missing
410
+ // calendar (permission not granted, fetch failed) shows a hint
411
+ // instead of silently vanishing.
412
+ const lines = this.calendarAvailable
413
+ ? [
414
+ "Calendar",
415
+ ...(this.calendarEvents.length
416
+ ? this.calendarEvents.slice(0, 3).map(eventLine)
417
+ : ["No upcoming events"]),
418
+ ]
419
+ : ["Calendar", "No access"]
420
+ elements.push({
421
+ type: "text",
422
+ id: "calendar",
423
+ box: {x: rightX, y: MARGIN_TOP, w: halfW, h: lines.length * LINE_H},
424
+ text: lines.join("\n"),
425
+ style: {font: "builtin", breakMode: "strict-word", overflow: "ellipsis"},
426
+ })
427
+ } else if (widget.id === "apps") {
428
+ const isSelected = selected?.kind === "apps-menu"
429
+ elements.push({
430
+ type: "text",
431
+ id: "apps-button",
432
+ box: {x: MARGIN_X, y, w: width, h: BUTTON_H},
433
+ text: "Apps",
434
+ style: {font: "builtin", border: isSelected ? 2 : 0, radius: 8},
435
+ })
436
+ y += BUTTON_H + WIDGET_GAP
437
+ } else if (widget.id === "notifications") {
438
+ const isSelected = selected?.kind === "notifications"
439
+ elements.push({
440
+ type: "text",
441
+ id: "notifications",
442
+ box: {x: MARGIN_X, y, w: width, h: BUTTON_H},
443
+ text: "Notifications",
444
+ style: {font: "builtin", border: isSelected ? 2 : 0, radius: 8},
445
+ })
446
+ y += BUTTON_H + WIDGET_GAP
447
+ }
448
+ }
449
+ return elements
450
+ }
451
+
452
+ /**
453
+ * Resolve a miniapp icon to base64 for the glasses. Local/bundled miniapps
454
+ * carry a file:// icon.png; store apps an https URL; the offline system
455
+ * tiles a bundler asset id (unsupported here — monogram fallback). Loads
456
+ * are async and cached; a finished load repaints the open app menu.
457
+ */
458
+ private loadIcon(app: ClientApp): void {
459
+ if (this.iconCache.has(app.packageName) || this.iconLoads.has(app.packageName)) return
460
+ const url = typeof app.logoUrl === "string" ? app.logoUrl : ""
461
+ if (!/^(file|https?):/.test(url)) {
462
+ this.iconCache.set(app.packageName, null)
463
+ return
464
+ }
465
+ this.iconLoads.add(app.packageName)
466
+ void (async () => {
467
+ try {
468
+ let base64: string
469
+ if (url.startsWith("file:")) {
470
+ base64 = await new File(url).base64()
471
+ } else {
472
+ const response = await fetch(url)
473
+ const blob = await response.blob()
474
+ base64 = await new Promise<string>((resolve, reject) => {
475
+ const reader = new FileReader()
476
+ reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "")
477
+ reader.onerror = () => reject(reader.error)
478
+ reader.readAsDataURL(blob)
479
+ })
480
+ }
481
+ this.iconCache.set(app.packageName, base64 || null)
482
+ } catch {
483
+ this.iconCache.set(app.packageName, null)
484
+ } finally {
485
+ this.iconLoads.delete(app.packageName)
486
+ if (this.open && this.view === "apps") this.render()
487
+ }
488
+ })()
489
+ }
490
+
491
+ private appMenuElements(): SceneElementInput[] {
492
+ const canvas = this.canvas()
493
+ const width = canvas.width - MARGIN_X * 2
494
+ const apps = this.menuApps()
495
+ const rows = appMenuActions(apps)
496
+ if (this.appsIndex >= rows.length) this.appsIndex = Math.max(0, rows.length - 1)
497
+
498
+ const elements: SceneElementInput[] = [
499
+ {
500
+ type: "text",
501
+ id: "apps-title",
502
+ box: {x: MARGIN_X, y: MARGIN_TOP, w: width, h: LINE_H},
503
+ text: "Apps",
504
+ style: {font: "builtin"},
505
+ },
506
+ ]
507
+ const listTop = MARGIN_TOP + LINE_H + 16
508
+ if (apps.length === 0) {
509
+ elements.push({
510
+ type: "text",
511
+ id: "apps-empty",
512
+ box: {x: MARGIN_X, y: listTop, w: width, h: LINE_H},
513
+ text: "No apps in Glasses Menu",
514
+ style: {font: "builtin"},
515
+ })
516
+ return elements
517
+ }
518
+
519
+ // Icon grid, paged: the G2 firmware image-container pool is 4, so at most
520
+ // four icon images may be on a page. Selection is the bordered cell.
521
+ const perPage = APPS_PER_PAGE
522
+ const pageStart = Math.floor(this.appsIndex / perPage) * perPage
523
+ const page = apps.slice(pageStart, pageStart + perPage)
524
+ const totalW = page.length * APP_CELL + (page.length - 1) * APP_CELL_GAP
525
+ const startX = Math.max(MARGIN_X, Math.round((canvas.width - totalW) / 2))
526
+ page.forEach((app, column) => {
527
+ const x = startX + column * (APP_CELL + APP_CELL_GAP)
528
+ const selected = pageStart + column === this.appsIndex
529
+ const icon = this.iconCache.get(app.packageName)
530
+ if (icon === undefined) this.loadIcon(app)
531
+ if (icon) {
532
+ elements.push({
533
+ type: "image",
534
+ id: `app-icon:${app.packageName}`,
535
+ box: {x: x + APP_ICON_INSET, y: listTop + APP_ICON_INSET, w: APP_ICON, h: APP_ICON},
536
+ data: icon,
537
+ })
538
+ } else {
539
+ // Monogram fallback for icons that can't load (bundler-asset tiles).
540
+ elements.push({
541
+ type: "text",
542
+ id: `app-icon:${app.packageName}`,
543
+ box: {x: x + APP_ICON_INSET, y: listTop + APP_ICON_INSET, w: APP_ICON, h: APP_ICON},
544
+ text: (app.name.trim()[0] ?? "?").toUpperCase(),
545
+ style: {font: "mono", fontSize: 40},
546
+ })
547
+ }
548
+ if (selected) {
549
+ elements.push({
550
+ type: "rect",
551
+ id: "apps-selection",
552
+ box: {x, y: listTop, w: APP_CELL, h: APP_CELL},
553
+ style: {border: 2, radius: 8},
554
+ })
555
+ }
556
+ })
557
+
558
+ // Grayscale mini icons are ambiguous on their own: caption the selection.
559
+ const selectedApp = apps[this.appsIndex]
560
+ if (selectedApp) {
561
+ elements.push({
562
+ type: "text",
563
+ id: "apps-selected-name",
564
+ box: {x: MARGIN_X, y: listTop + APP_CELL + 12, w: width, h: LINE_H},
565
+ text: `${selectedApp.name}${selectedApp.running ? " ●" : ""}`,
566
+ style: {font: "builtin", breakMode: "strict-word", overflow: "ellipsis"},
567
+ })
568
+ }
569
+ return elements
570
+ }
571
+ }
572
+
573
+ const customDashboardService = new CustomDashboardService()
574
+ export default customDashboardService
575
+ export {CustomDashboardService}
@@ -20,7 +20,9 @@ import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
20
20
  import {shallow} from "zustand/shallow"
21
21
 
22
22
  import localMiniappRuntime from "./LocalMiniappRuntime"
23
+ import localDisplayManager from "./LocalDisplayManager"
23
24
  import localSttFallbackCoordinator from "./LocalSttFallbackCoordinator"
25
+ import {MiniappExitGesture} from "./MiniappExitGesture"
24
26
  import nativeNavigationService from "./NativeNavigationService"
25
27
  import speakerLabelResolver from "./SpeakerLabelResolver"
26
28
  import {phonePhotoCoordinator} from "./PhonePhotoCoordinator"
@@ -31,10 +33,23 @@ import {SETTINGS, useSettingsStore} from "../stores/settings"
31
33
  import {useAppStatusStore} from "../stores/apps"
32
34
  import {retirePendingSelectionOnPromotion} from "./PairingIdentity"
33
35
  import GlobalEventEmitter from "../utils/GlobalEventEmitter"
36
+ import {BgTimer} from "../utils/timers"
34
37
  import {asgCameraApi} from "./asg/asgCameraApi"
38
+ import customDashboardService from "./CustomDashboardService"
39
+ import gestureDotsService from "./GestureDotsService"
35
40
 
36
41
  let subs: Array<{remove: () => void}> = []
37
42
 
43
+ const miniappExitGesture = new MiniappExitGesture({
44
+ getState: () => useAppStatusStore.getState(),
45
+ getVisiblePackageName: () => localDisplayManager.getVisibleMiniappPackageName(),
46
+ beginExitNotice: (packageName, text) => localDisplayManager.beginMiniappExit(packageName, text),
47
+ finishExitNotice: (packageName) => localDisplayManager.finishMiniappExit(packageName),
48
+ cancelExitNotice: (packageName) => localDisplayManager.cancelMiniappExit(packageName),
49
+ setTimeout: (callback, delay) => BgTimer.setTimeout(callback, delay),
50
+ clearTimeout: (timerId) => BgTimer.clearTimeout(timerId),
51
+ })
52
+
38
53
  export function startDeviceEventRouter(): void {
39
54
  if (subs.length) return
40
55
 
@@ -183,13 +198,38 @@ export function startDeviceEventRouter(): void {
183
198
  )
184
199
  subs.push(
185
200
  BluetoothSdk.addListener("touch_event", (event) => {
201
+ // While the custom dashboard is visible it owns its focus/navigation
202
+ // gestures; do not leak those into a miniapp running underneath it.
203
+ if (customDashboardService.handleGesture(event.gestureName)) return
204
+
205
+ // Gesture-dots debug indicator (any device, gated by its setting). It
206
+ // only paints while the screen is otherwise empty; the gesture still
207
+ // flows to miniapps below.
208
+ gestureDotsService.noteGesture(event.gestureName)
209
+
186
210
  localMiniappRuntime.forwardEvent("touch_event", event)
187
- // Double-tap policy, moved out of the native G2 handlers: open the native
188
- // dashboard when the setting is on, else toggle head-up. Native only
189
- // reports the gesture now.
211
+ // Double-tap policy, moved out of the native G2 handlers: exit a visible
212
+ // unclaimed miniapp; otherwise open the dashboard or toggle head-up.
213
+ // Native only reports the gesture now.
190
214
  if (event.gestureName === "double_tap") {
191
- const useNativeDashboard =
192
- useSettingsStore.getState().getSetting(SETTINGS.use_native_dashboard.key) !== false
215
+ // A foreground miniapp that claimed double_tap (CLAIM_GESTURES) is
216
+ // using it as an in-app gesture (e.g. the terminal's back-to-menu)
217
+ // the event was already forwarded above; skip the system default.
218
+ if (localMiniappRuntime.foregroundClaimsGesture("double_tap")) return
219
+
220
+ // An unclaimed double tap exits only the miniapp whose frame is visibly
221
+ // on the glasses. The controller shows the bounded OS notice first,
222
+ // then uses the normal store stop path (and closes its phone overlay).
223
+ // Checked before the dashboard toggle: while an app owns the screen an
224
+ // unclaimed double-tap always means "leave the app" — the (custom or
225
+ // native) dashboard is for the empty screen, and head-up still opens
226
+ // the custom dashboard mid-app.
227
+ if (miniappExitGesture.tryExitVisibleMiniapp()) return
228
+
229
+ // Eligible G2 custom firmware replaces the native dashboard.
230
+ if (customDashboardService.toggle()) return
231
+
232
+ const useNativeDashboard = useSettingsStore.getState().getSetting(SETTINGS.use_native_dashboard.key) !== false
193
233
  if (useNativeDashboard) {
194
234
  // The modal nav UI owns the screen; showDashboard would fight it.
195
235
  if (!nativeNavigationService.isActive) {
@@ -219,6 +259,7 @@ export function startDeviceEventRouter(): void {
219
259
  // Head position — translate native {up:boolean} → SDK {position:"up"|"down"}.
220
260
  subs.push(
221
261
  BluetoothSdk.addListener("head_up", (event) => {
262
+ customDashboardService.setHeadUp(event.up)
222
263
  localMiniappRuntime.forwardEvent("head_up", {position: event.up ? "up" : "down", timestamp: Date.now()})
223
264
  }),
224
265
  )
@@ -263,4 +304,5 @@ export function startDeviceEventRouter(): void {
263
304
  export function stopDeviceEventRouter(): void {
264
305
  for (const sub of subs) sub.remove()
265
306
  subs = []
307
+ miniappExitGesture.reset()
266
308
  }