@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,147 @@
1
+ /**
2
+ * Gesture dots — a debug indicator that flashes a small glyph top-right on the
3
+ * glasses when a touch gesture arrives, while nothing else is on the display:
4
+ * a filled circle for a single tap, an arrow for a swipe, cleared after a
5
+ * short pulse. Extracted from the CFW dashboard service; it renders through
6
+ * the ordinary scene pipeline, so it works on any positioning device (stock
7
+ * G2 included), and is gated by the gesture_dots debug setting.
8
+ *
9
+ * The glyphs are pre-baked images (not text): the stock firmware's builtin
10
+ * font has no ●/↑/↓, and a single image element with a stable id and box maps
11
+ * to ONE re-used firmware image container — flash and idle swap its bytes
12
+ * (data-only update, no page rebuild) instead of creating/removing containers.
13
+ */
14
+ import {SETTINGS, useSettingsStore} from "../stores/settings"
15
+ import type {SceneElementInput} from "../utils/display/scene"
16
+ import {BgTimer} from "../utils/timers"
17
+ import {GESTURE_DOT_GLYPHS, GESTURE_DOT_GLYPH_SIZE, type GestureDotGlyph} from "./gestureDotGlyphs"
18
+ import localDisplayManager from "./LocalDisplayManager"
19
+ import sceneRenderer from "./SceneRenderer"
20
+
21
+ const DOTS_APP_ID = "system.gesture-dots"
22
+ /** How long a gesture stays on the indicator. */
23
+ const GESTURE_FLASH_MS = 700
24
+ const MARGIN_X = 16
25
+ const MARGIN_TOP = 10
26
+
27
+ class GestureDotsService {
28
+ private started = false
29
+ private flash: GestureDotGlyph | null = null
30
+ private flashTimer: number | null = null
31
+ /** Finger currently on the touch bar (tap_start..tap_end; the CFW forwards holds as these edges). */
32
+ private held = false
33
+ /** Whether the indicator scene currently has anything on it (needs a clear when the flash ends). */
34
+ private visible = false
35
+ private unsubs: Array<() => void> = []
36
+
37
+ start(): void {
38
+ if (this.started) return
39
+ this.started = true
40
+ this.unsubs.push(
41
+ // The indicator owns the screen only while it is empty: drop our
42
+ // retained state when something else takes over, and clear any
43
+ // leftover flash when the display empties again.
44
+ localDisplayManager.onActiveDisplayChange((active) => {
45
+ if (active) this.visible = false
46
+ else this.render()
47
+ }),
48
+ )
49
+ }
50
+
51
+ stop(): void {
52
+ for (const unsub of this.unsubs) unsub()
53
+ this.unsubs = []
54
+ if (this.flashTimer !== null) BgTimer.clearTimeout(this.flashTimer)
55
+ this.flashTimer = null
56
+ this.flash = null
57
+ this.held = false
58
+ this.visible = false
59
+ this.started = false
60
+ sceneRenderer.clearApp(DOTS_APP_ID)
61
+ }
62
+
63
+ private enabled(): boolean {
64
+ return useSettingsStore.getState().getSetting(SETTINGS.gesture_dots.key) === true
65
+ }
66
+
67
+ /**
68
+ * Flash a gesture on the indicator. Other gesture names are ignored. The
69
+ * device-event router calls this after the CFW dashboard had its chance to
70
+ * consume the gesture, so dashboard navigation does not flash.
71
+ */
72
+ noteGesture(gestureName: string): void {
73
+ if (!this.started || !this.enabled()) return
74
+ // Tap edges show a steady circle for as long as the finger is down (on
75
+ // CFW these arrive for holds: ET_LONG/ET_REL forwarded as
76
+ // tap_start/tap_end), with no timer — tap_end clears it.
77
+ if (gestureName === "tap_start" || gestureName === "tap_end") {
78
+ const held = gestureName === "tap_start"
79
+ if (this.held === held) return
80
+ this.held = held
81
+ this.render()
82
+ return
83
+ }
84
+ const flash: GestureDotGlyph | null =
85
+ gestureName === "single_tap"
86
+ ? "dot"
87
+ : gestureName === "swipe_up"
88
+ ? "up"
89
+ : gestureName === "swipe_down"
90
+ ? "down"
91
+ : null
92
+ if (!flash) return
93
+ this.flash = flash
94
+ if (this.flashTimer !== null) BgTimer.clearTimeout(this.flashTimer)
95
+ this.flashTimer = BgTimer.setTimeout(() => {
96
+ this.flashTimer = null
97
+ this.flash = null
98
+ this.render()
99
+ }, GESTURE_FLASH_MS)
100
+ this.render()
101
+ }
102
+
103
+ /**
104
+ * Paint or clear the indicator. Nothing is drawn while anything else is on
105
+ * the glasses (miniapp frame or system screen) or when no gesture was just
106
+ * received. The top-right anchor resolves against the device's real canvas,
107
+ * so no size math here.
108
+ *
109
+ * Between flashes the element STAYS in the scene with the blank glyph:
110
+ * removing it would tear down the firmware image container and rebuild the
111
+ * page on every flash (a rebuild storm on stock firmware). Only disabling
112
+ * the setting removes the element outright.
113
+ */
114
+ private render(): void {
115
+ if (!this.started) return
116
+ if (localDisplayManager.hasActiveDisplay()) return
117
+ const shown = this.flash ?? (this.held ? "dot" : null)
118
+ if (!this.enabled()) {
119
+ if (this.visible) {
120
+ this.visible = false
121
+ sceneRenderer.emitScene(DOTS_APP_ID, "main", [])
122
+ }
123
+ return
124
+ }
125
+ if (!shown && !this.visible) return
126
+ const elements: SceneElementInput[] = [
127
+ {
128
+ type: "image",
129
+ id: "gesture-dot",
130
+ box: {
131
+ x: MARGIN_X,
132
+ y: MARGIN_TOP,
133
+ w: GESTURE_DOT_GLYPH_SIZE,
134
+ h: GESTURE_DOT_GLYPH_SIZE,
135
+ anchor: "top-right",
136
+ },
137
+ data: GESTURE_DOT_GLYPHS[shown ?? "blank"],
138
+ },
139
+ ]
140
+ this.visible = true
141
+ sceneRenderer.emitScene(DOTS_APP_ID, "main", elements)
142
+ }
143
+ }
144
+
145
+ const gestureDotsService = new GestureDotsService()
146
+ export default gestureDotsService
147
+ export {GestureDotsService}
@@ -11,22 +11,26 @@
11
11
  *
12
12
  * Started by `engine.start()`. Idempotent.
13
13
  */
14
- import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
14
+ import BluetoothSdk, {type PublicGlassesStatus} from "@glassly/bluetooth-sdk"
15
15
  import {useCoreStore} from "../stores/core"
16
16
  import {useGlassesStore} from "../stores/glasses"
17
- import localMiniappRuntime from "./LocalMiniappRuntime"
18
17
 
19
18
  let unsubs: Array<() => void> = []
20
19
  let projectionRunId = 0
20
+ let glassesStatusForwarder: ((status: Partial<PublicGlassesStatus>) => void) | null = null
21
+ let hydrationPromise: Promise<void> | null = null
21
22
 
22
- export function startGlassesStatusProjection(): void {
23
- if (unsubs.length) return
23
+ export function startGlassesStatusProjection(
24
+ forwarder?: (status: Partial<PublicGlassesStatus>) => void,
25
+ ): Promise<void> {
26
+ if (forwarder) glassesStatusForwarder = forwarder
27
+ if (unsubs.length) return hydrationPromise ?? Promise.resolve()
24
28
 
25
29
  const runId = ++projectionRunId
26
30
  let bluetoothEventSeen = false
27
31
  let glassesEventSeen = false
28
32
 
29
- void BluetoothSdk.getBluetoothStatus()
33
+ const bluetoothHydration = BluetoothSdk.getBluetoothStatus()
30
34
  .then((status) => {
31
35
  if (runId !== projectionRunId || bluetoothEventSeen) return
32
36
  useCoreStore.getState().setCoreInfo(status)
@@ -35,7 +39,7 @@ export function startGlassesStatusProjection(): void {
35
39
  console.warn("GlassesStatusProjection: getBluetoothStatus failed", error)
36
40
  })
37
41
 
38
- void BluetoothSdk.getGlassesStatus()
42
+ const glassesHydration = BluetoothSdk.getGlassesStatus()
39
43
  .then((status) => {
40
44
  if (runId !== projectionRunId || glassesEventSeen) return
41
45
  useGlassesStore.getState().setGlassesInfo(status)
@@ -46,28 +50,33 @@ export function startGlassesStatusProjection(): void {
46
50
 
47
51
  // Bluetooth-adapter status -> core store.
48
52
  unsubs.push(
49
- BluetoothSdk.onBluetoothStatus((changed) => {
53
+ BluetoothSdk.subscribeBluetoothStatus((changed) => {
50
54
  bluetoothEventSeen = true
51
55
  useCoreStore.getState().setCoreInfo(changed)
52
56
  }),
53
57
  )
54
58
 
55
- // Glasses status -> glasses store (+ forward to local miniapps; clear any stale
56
- // OTA-available flag on disconnect).
59
+ // Glasses status -> glasses store (+ optional full-runtime forwarding to local
60
+ // miniapps; clear any stale OTA-available flag on disconnect).
57
61
  unsubs.push(
58
- BluetoothSdk.onGlassesStatus((changed) => {
62
+ BluetoothSdk.subscribeGlassesStatus((changed) => {
59
63
  glassesEventSeen = true
60
64
  useGlassesStore.getState().setGlassesInfo(changed)
61
- localMiniappRuntime.forwardEvent("glasses_connection_state", changed)
65
+ glassesStatusForwarder?.(changed)
62
66
  if (changed.connection?.state === "disconnected") {
63
67
  useGlassesStore.getState().setOtaUpdateAvailable(null)
64
68
  }
65
69
  }),
66
70
  )
71
+
72
+ hydrationPromise = Promise.allSettled([bluetoothHydration, glassesHydration]).then(() => undefined)
73
+ return hydrationPromise
67
74
  }
68
75
 
69
76
  export function stopGlassesStatusProjection(): void {
70
77
  projectionRunId++
71
78
  unsubs.forEach((unsub) => unsub())
72
79
  unsubs = []
80
+ glassesStatusForwarder = null
81
+ hydrationPromise = null
73
82
  }
@@ -1,4 +1,5 @@
1
- import BluetoothSdk, {isEnabledHotspotStatus, OtaServer} from "@glassly/bluetooth-sdk/internal"
1
+ import BluetoothSdk, {isEnabledHotspotStatus} from "@glassly/bluetooth-sdk"
2
+ import {otaServer} from "@glassly/bluetooth-sdk/ota-transport"
2
3
  // One coordinator selects the native Android or iOS staging implementation at runtime.
3
4
  // eslint-disable-next-line react-native/split-platform-components
4
5
  import {PermissionsAndroid, Platform} from "react-native"
@@ -83,7 +84,7 @@ class HotspotOtaTransport {
83
84
  let localAddress = scopedAddress
84
85
  if (Platform.OS === "ios") {
85
86
  try {
86
- localAddress = await OtaServer.waitForWifiAddress(hotspot.localIp, 15_000)
87
+ localAddress = await otaServer.waitForWifiAddress(hotspot.localIp, 15_000)
87
88
  } catch (error) {
88
89
  throw new HotspotOtaTransportError(
89
90
  "hotspot_join_failed",
@@ -94,10 +95,10 @@ class HotspotOtaTransport {
94
95
  const artifactPaths = Object.fromEntries(this.prepared.map((artifact) => [artifact.sha256, artifact.filePath]))
95
96
  // Start exactly one native listener to learn its selected port, then atomically replace
96
97
  // the placeholder with the immutable rewritten manifest before ota_start is sent.
97
- const server = await OtaServer.startOtaServer("{}", artifactPaths, localAddress)
98
+ const server = await otaServer.start("{}", artifactPaths, localAddress)
98
99
  this.serverStarted = true
99
100
  const manifest = rewriteManifestForLocalServer(checkResult.manifestBody, this.prepared, server.baseUrl)
100
- const published = await OtaServer.startOtaServer(manifest, artifactPaths, server.host)
101
+ const published = await otaServer.start(manifest, artifactPaths, server.host)
101
102
  if (published.manifestUrl !== server.manifestUrl) {
102
103
  throw new Error("Local OTA server endpoint changed while publishing the manifest")
103
104
  }
@@ -141,11 +142,11 @@ class HotspotOtaTransport {
141
142
  destination: string,
142
143
  onProgress?: (bytesWritten: number, contentLength: number) => void,
143
144
  ): Promise<{statusCode: number}> => {
144
- const subscription = OtaServer.addListener("artifactDownloadProgress", (event) => {
145
+ const subscription = otaServer.onArtifactDownloadProgress((event) => {
145
146
  if (event.destination === destination) onProgress?.(event.bytesWritten, event.contentLength)
146
147
  })
147
148
  try {
148
- return await OtaServer.downloadArtifact(entry.url, destination)
149
+ return await otaServer.downloadArtifact(entry.url, destination)
149
150
  } finally {
150
151
  subscription.remove()
151
152
  }
@@ -164,7 +165,7 @@ class HotspotOtaTransport {
164
165
  console.warn("[OTA_PROGRESS] glasses hotspot shutdown was not confirmed after bounded retries")
165
166
  }
166
167
  }
167
- if (this.serverStarted) await OtaServer.stopOtaServer().catch(() => {})
168
+ if (this.serverStarted) await otaServer.stop().catch(() => {})
168
169
  if (this.localNetworkConnected) await localNetworkTransport.disconnect().catch(() => {})
169
170
  await cleanupArtifacts().catch(() => {})
170
171
  this.prepared = []
@@ -115,12 +115,47 @@ class LocalDisplayManager {
115
115
 
116
116
  private coreApp: string | null = null
117
117
  private coreAppDisplay: ActiveDisplay | null = null
118
- private currentDisplay: ActiveDisplay | null = null
118
+ private _currentDisplay: ActiveDisplay | null = null
119
119
  private backgroundLock: BackgroundLock | null = null
120
120
 
121
+ /** Fired when the display transitions between empty and occupied (any package, system screens included). */
122
+ private activeDisplayListeners = new Set<(active: boolean) => void>()
123
+
124
+ private get currentDisplay(): ActiveDisplay | null {
125
+ return this._currentDisplay
126
+ }
127
+
128
+ private set currentDisplay(value: ActiveDisplay | null) {
129
+ const wasActive = this._currentDisplay !== null
130
+ this._currentDisplay = value
131
+ const isActive = value !== null
132
+ if (wasActive === isActive) return
133
+ for (const listener of this.activeDisplayListeners) {
134
+ try {
135
+ listener(isActive)
136
+ } catch (error) {
137
+ console.error(`${LOG_TAG}: onActiveDisplayChange listener failed:`, error)
138
+ }
139
+ }
140
+ }
141
+
142
+ /** True while anything (miniapp or system screen) is on the glasses display. */
143
+ public hasActiveDisplay(): boolean {
144
+ return this._currentDisplay !== null
145
+ }
146
+
147
+ /** Subscribe to empty↔occupied display transitions. Returns an unsubscribe. */
148
+ public onActiveDisplayChange(listener: (active: boolean) => void): () => void {
149
+ this.activeDisplayListeners.add(listener)
150
+ return () => this.activeDisplayListeners.delete(listener)
151
+ }
152
+
121
153
  private bootingApp: BootingApp | null = null
122
154
  private bootQueue: Map<string, {payload: DisplayPayload; resolve?: DisplayRequestResolver}> = new Map()
123
155
 
156
+ /** Miniapp whose visible frame is temporarily covered by an OS exit notice. */
157
+ private exitingApp: {packageName: string; coveredDisplay: ActiveDisplay} | null = null
158
+
124
159
  private expiryTimerId: number | null = null
125
160
 
126
161
  /** Reconnect-replay subscription state (attachToRuntime). */
@@ -200,7 +235,7 @@ class LocalDisplayManager {
200
235
  */
201
236
  public showSystemNotice(text: string, durationMs: number = SYSTEM_NOTICE_DURATION_MS): void {
202
237
  if (!isGlassesConnected(useGlassesStore.getState().connection)) return
203
- if (this.bootingApp) return
238
+ if (this.bootingApp || this.exitingApp) return
204
239
  console.log(`${LOG_TAG}: showSystemNotice("${text}")`)
205
240
  this.sendNow(SYSTEM_NOTICE_PKG, {
206
241
  view: "main",
@@ -228,6 +263,88 @@ class LocalDisplayManager {
228
263
  }
229
264
  }
230
265
 
266
+ /**
267
+ * The package that currently owns the glasses screen, for gesture-claim
268
+ * arbitration (see LocalMiniappRuntime.foregroundClaimsGesture). This is
269
+ * glasses ownership, not the phone's Compositor foreground: a background
270
+ * miniapp rendering to the glasses with the phone pocketed is the owner.
271
+ *
272
+ * The live frame's owner wins; a background app that holds the display lock
273
+ * but has no frame up right now (between renders) still counts. System
274
+ * frames (boot, notices) own nothing claimable. Returns null when the
275
+ * glasses are idle.
276
+ */
277
+ public getGestureOwner(): string | null {
278
+ const current = this.currentDisplay?.packageName ?? null
279
+ if (current && current !== SYSTEM_BOOT_PKG && current !== SYSTEM_NOTICE_PKG) return current
280
+ const lock = this.backgroundLock
281
+ if (lock && this.now() <= lock.expiresAt) return lock.packageName
282
+ return null
283
+ }
284
+
285
+ /**
286
+ * The miniapp whose frame is visibly on the glasses right now.
287
+ *
288
+ * Unlike getGestureOwner(), this deliberately does not fall back to a
289
+ * background display lock: a lock holder with no live frame is not visible
290
+ * and therefore must not be closed by the system double-tap gesture.
291
+ */
292
+ public getVisibleMiniappPackageName(): string | null {
293
+ const current = this.currentDisplay?.packageName ?? null
294
+ if (!current || current === SYSTEM_BOOT_PKG || current === SYSTEM_NOTICE_PKG) return null
295
+ return current
296
+ }
297
+
298
+ /**
299
+ * Cover the currently visible miniapp with a persistent system exit notice.
300
+ * While it is up, miniapp display traffic is held off so a streaming app
301
+ * cannot immediately overwrite the notice. The caller completes the normal
302
+ * stop flow and then calls finishMiniappExit(); onUnmount does the same as a
303
+ * safety net.
304
+ */
305
+ public beginMiniappExit(packageName: string, text: string): boolean {
306
+ if (this.exitingApp !== null) return this.exitingApp.packageName === packageName
307
+ const coveredDisplay = this.currentDisplay
308
+ if (!coveredDisplay || this.getVisibleMiniappPackageName() !== packageName) return false
309
+
310
+ console.log(`${LOG_TAG}: beginMiniappExit(${packageName})`)
311
+ this.exitingApp = {packageName, coveredDisplay}
312
+ this.clearExpiryTimer()
313
+ this.sendNow(SYSTEM_NOTICE_PKG, {
314
+ view: "main",
315
+ layout: {layoutType: "text_wall", text},
316
+ })
317
+ return true
318
+ }
319
+
320
+ /** Remove an exit notice and resume the display state normal app teardown leaves behind. */
321
+ public finishMiniappExit(packageName: string): void {
322
+ if (this.exitingApp?.packageName !== packageName) return
323
+ console.log(`${LOG_TAG}: finishMiniappExit(${packageName})`)
324
+ this.exitingApp = null
325
+
326
+ if (this.currentDisplay?.packageName !== SYSTEM_NOTICE_PKG) return
327
+ this.currentDisplay = null
328
+ this.tryRestoreCoreDisplay()
329
+ if (!this.currentDisplay) this.sendClear()
330
+ }
331
+
332
+ /** Cancel the handoff and put back the frame the exit notice covered. */
333
+ public cancelMiniappExit(packageName: string): void {
334
+ if (this.exitingApp?.packageName !== packageName) return
335
+ const covered = this.exitingApp.coveredDisplay
336
+ this.exitingApp = null
337
+
338
+ if (this.currentDisplay?.packageName !== SYSTEM_NOTICE_PKG) return
339
+ if (covered.expiresAt === null || covered.expiresAt > this.now()) {
340
+ this.restoreDisplay(covered)
341
+ } else {
342
+ this.currentDisplay = null
343
+ this.tryRestoreCoreDisplay()
344
+ if (!this.currentDisplay) this.sendClear()
345
+ }
346
+ }
347
+
231
348
  /** Report-safe ownership snapshot for incident diagnostics. */
232
349
  public getDiagnosticSnapshot(): Record<string, unknown> {
233
350
  return {
@@ -235,6 +352,7 @@ class LocalDisplayManager {
235
352
  currentDisplayPackageName: this.currentDisplay?.packageName ?? null,
236
353
  backgroundLockPackageName: this.backgroundLock?.packageName ?? null,
237
354
  bootingPackageName: this.bootingApp?.packageName ?? null,
355
+ exitingPackageName: this.exitingApp?.packageName ?? null,
238
356
  }
239
357
  }
240
358
 
@@ -297,6 +415,8 @@ class LocalDisplayManager {
297
415
  this.sendClear()
298
416
  }
299
417
  }
418
+
419
+ this.finishMiniappExit(packageName)
300
420
  }
301
421
 
302
422
  // ===========================================================================
@@ -313,6 +433,13 @@ class LocalDisplayManager {
313
433
  * the boot queue (resolved when drained or superseded).
314
434
  */
315
435
  public request(packageName: string, payload: DisplayPayload, resolve?: DisplayRequestResolver): void {
436
+ // Keep the OS exit notice visible for its short handoff window. Without
437
+ // this, streaming captions/terminal frames can replace it immediately.
438
+ if (this.exitingApp !== null) {
439
+ resolve?.({status: "blocked", reason: "a miniapp exit notice is active"})
440
+ return
441
+ }
442
+
316
443
  // Dashboard view: pass straight through (no throttle/arbitration). Local
317
444
  // dashboard rendering is currently a stub on the phone anyway.
318
445
  if (payload.view === "dashboard") {
@@ -785,6 +912,7 @@ class LocalDisplayManager {
785
912
  this.coreAppDisplay = null
786
913
  this.currentDisplay = null
787
914
  this.backgroundLock = null
915
+ this.exitingApp = null
788
916
  this.bootQueue.clear()
789
917
  this.now = () => Date.now()
790
918
  this.unsubscribeGlassesStatus?.()
@@ -52,6 +52,7 @@ import {normalizeAiTier, resolveAiTier} from "./AiTierResolution"
52
52
  import type {AiTier, AiTierProvider, SavedLlmKeys} from "./AiTierResolution"
53
53
  import {HttpError} from "@glassly/cloud-client"
54
54
  import localDisplayManager from "./LocalDisplayManager"
55
+ import {claimsGesture} from "./gestureClaims"
55
56
  import nativeNavigationService from "./NativeNavigationService"
56
57
  import type {DisplayPayload} from "./LocalDisplayManager"
57
58
  import headingService from "./HeadingService"
@@ -539,6 +540,8 @@ class LocalMiniappRuntime {
539
540
 
540
541
  /** Connected miniapps keyed by packageName. */
541
542
  private connectedApps: Map<string, ConnectedMiniapp> = new Map()
543
+ /** Per-app claimed gestures (CLAIM_GESTURES) — see foregroundClaimsGesture. */
544
+ private gestureClaims: Map<string, Set<string>> = new Map()
542
545
 
543
546
  /**
544
547
  * Packages that have completed their CONNECT handshake (sent
@@ -1173,6 +1176,7 @@ class LocalMiniappRuntime {
1173
1176
  // core app's saved frame).
1174
1177
  localDisplayManager.onUnmount(packageName)
1175
1178
 
1179
+ this.gestureClaims.delete(packageName)
1176
1180
  this.connectedApps.delete(packageName)
1177
1181
  this.recomputeMicRequirements()
1178
1182
  if (releasedMicGateOverride) {
@@ -1251,6 +1255,9 @@ class LocalMiniappRuntime {
1251
1255
  case MiniappRequestType.SUBSCRIBE:
1252
1256
  this.handleSubscribe(packageName, payload, requestId)
1253
1257
  break
1258
+ case MiniappRequestType.CLAIM_GESTURES:
1259
+ this.handleClaimGestures(packageName, payload, requestId)
1260
+ break
1254
1261
  case MiniappRequestType.DISPLAY:
1255
1262
  this.handleDisplay(packageName, payload, requestId)
1256
1263
  break
@@ -1858,6 +1865,36 @@ class LocalMiniappRuntime {
1858
1865
  this.sendResult(packageName, requestId, true, {applied: true})
1859
1866
  }
1860
1867
 
1868
+ /**
1869
+ * CLAIM_GESTURES (session.input.setClaimedGestures): the app declares which
1870
+ * touch gestures it handles itself, replacing its previous claim set.
1871
+ * Claims suppress the host's default behavior for those gestures (today:
1872
+ * double_tap → dashboard/head-up in DeviceEventRouter) but ONLY while the
1873
+ * claiming app is the one in front of the user — see
1874
+ * {@link foregroundClaimsGesture}. Cleared on app stop.
1875
+ */
1876
+ private handleClaimGestures(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
1877
+ if (!this.connectedApps.has(packageName)) {
1878
+ this.sendResult(packageName, requestId, true)
1879
+ return
1880
+ }
1881
+ const raw = payload.gestures
1882
+ const gestures = Array.isArray(raw) ? raw.filter((g): g is string => typeof g === "string") : []
1883
+ if (gestures.length === 0) this.gestureClaims.delete(packageName)
1884
+ else this.gestureClaims.set(packageName, new Set(gestures))
1885
+ this.sendResult(packageName, requestId, true)
1886
+ }
1887
+
1888
+ /** True iff the miniapp in front of the user has claimed `gesture`. */
1889
+ public foregroundClaimsGesture(gesture: string): boolean {
1890
+ return claimsGesture(
1891
+ this.gestureClaims,
1892
+ gesture,
1893
+ localDisplayManager.getGestureOwner(),
1894
+ useAppStatusStore.getState().foregroundedPackage,
1895
+ )
1896
+ }
1897
+
1861
1898
  private handleSubscribe(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
1862
1899
  const app = this.connectedApps.get(packageName)
1863
1900
  if (!app) return
@@ -0,0 +1,76 @@
1
+ export const MINIAPP_EXIT_NOTICE_MS = 1500
2
+
3
+ interface ExitGestureApp {
4
+ packageName: string
5
+ name: string
6
+ running: boolean
7
+ }
8
+
9
+ interface ExitGestureState {
10
+ apps: ExitGestureApp[]
11
+ foregroundedPackage: string | null
12
+ clearForeground: () => void
13
+ stop: (packageName: string) => Promise<void>
14
+ }
15
+
16
+ export interface ExitGestureDependencies {
17
+ getState: () => ExitGestureState
18
+ getVisiblePackageName: () => string | null
19
+ beginExitNotice: (packageName: string, text: string) => boolean
20
+ finishExitNotice: (packageName: string) => void
21
+ cancelExitNotice: (packageName: string) => void
22
+ setTimeout: (callback: () => void, delay: number) => number
23
+ clearTimeout: (timerId: number) => void
24
+ }
25
+
26
+ /** Owns the short double-tap → exit-notice → normal-stop handoff. */
27
+ export class MiniappExitGesture {
28
+ private pending: {packageName: string; timerId: number} | null = null
29
+
30
+ public constructor(private readonly deps: ExitGestureDependencies) {}
31
+
32
+ /** Returns true when the system gesture was consumed by a visible miniapp exit. */
33
+ public tryExitVisibleMiniapp(): boolean {
34
+ // Swallow repeat taps while the notice is already counting down.
35
+ if (this.pending) return true
36
+
37
+ const packageName = this.deps.getVisiblePackageName()
38
+ if (!packageName) return false
39
+
40
+ const app = this.deps.getState().apps.find((candidate) => candidate.packageName === packageName)
41
+ if (!app?.running) return false
42
+
43
+ const name = app.name.trim() || packageName
44
+ if (!this.deps.beginExitNotice(packageName, `Exiting ${name}…`)) return false
45
+
46
+ const timerId = this.deps.setTimeout(() => {
47
+ const state = this.deps.getState()
48
+ if (state.foregroundedPackage === packageName) state.clearForeground()
49
+
50
+ const stillRunning = state.apps.some((candidate) => candidate.packageName === packageName && candidate.running)
51
+ const stop = stillRunning ? state.stop(packageName) : Promise.resolve()
52
+ void stop
53
+ .catch((error) => {
54
+ console.error(`MiniappExitGesture: failed to stop ${packageName}:`, error)
55
+ })
56
+ .finally(() => {
57
+ if (this.pending?.packageName === packageName) this.pending = null
58
+ // LocalMiniappRuntime.onUnmount normally finishes the notice. This
59
+ // fallback also handles a failed/no-op teardown.
60
+ this.deps.finishExitNotice(packageName)
61
+ })
62
+ }, MINIAPP_EXIT_NOTICE_MS)
63
+
64
+ this.pending = {packageName, timerId}
65
+ return true
66
+ }
67
+
68
+ /** Cancel an in-flight handoff when the device-event router shuts down. */
69
+ public reset(): void {
70
+ if (!this.pending) return
71
+ const {packageName, timerId} = this.pending
72
+ this.pending = null
73
+ this.deps.clearTimeout(timerId)
74
+ this.deps.cancelExitNotice(packageName)
75
+ }
76
+ }