@glassly/engine 0.3.5 → 0.3.6-beta.851

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 (206) hide show
  1. package/build/engine.d.ts +11 -1
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +13 -1
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/ai.d.ts +3 -3
  6. package/build/facades/ai.d.ts.map +1 -1
  7. package/build/facades/ai.js +3 -3
  8. package/build/facades/ai.js.map +1 -1
  9. package/build/facades/glasses.d.ts +5 -1
  10. package/build/facades/glasses.d.ts.map +1 -1
  11. package/build/facades/glasses.js +39 -30
  12. package/build/facades/glasses.js.map +1 -1
  13. package/build/facades/memory.d.ts +10 -2
  14. package/build/facades/memory.d.ts.map +1 -1
  15. package/build/facades/memory.js +10 -0
  16. package/build/facades/memory.js.map +1 -1
  17. package/build/facades/miniapps.d.ts +2 -0
  18. package/build/facades/miniapps.d.ts.map +1 -1
  19. package/build/facades/miniapps.js +2 -0
  20. package/build/facades/miniapps.js.map +1 -1
  21. package/build/facades/pairing.d.ts.map +1 -1
  22. package/build/facades/pairing.js +3 -2
  23. package/build/facades/pairing.js.map +1 -1
  24. package/build/facades/providerKeys.d.ts +6 -2
  25. package/build/facades/providerKeys.d.ts.map +1 -1
  26. package/build/facades/providerKeys.js +6 -2
  27. package/build/facades/providerKeys.js.map +1 -1
  28. package/build/generated/releaseMetadata.js +4 -4
  29. package/build/generated/releaseMetadata.js.map +1 -1
  30. package/build/services/AiTierResolution.d.ts +3 -2
  31. package/build/services/AiTierResolution.d.ts.map +1 -1
  32. package/build/services/AiTierResolution.js +7 -2
  33. package/build/services/AiTierResolution.js.map +1 -1
  34. package/build/services/AppRegistry.d.ts +4 -1
  35. package/build/services/AppRegistry.d.ts.map +1 -1
  36. package/build/services/AppRegistry.js +16 -2
  37. package/build/services/AppRegistry.js.map +1 -1
  38. package/build/services/BlobStore.js +3 -3
  39. package/build/services/BlobStore.js.map +1 -1
  40. package/build/services/BluetoothRestoreLaunch.d.ts +8 -0
  41. package/build/services/BluetoothRestoreLaunch.d.ts.map +1 -0
  42. package/build/services/BluetoothRestoreLaunch.js +34 -0
  43. package/build/services/BluetoothRestoreLaunch.js.map +1 -0
  44. package/build/services/CloudClientService.d.ts +4 -4
  45. package/build/services/ForegroundLocationPermission.d.ts +1 -1
  46. package/build/services/ForegroundLocationPermission.d.ts.map +1 -1
  47. package/build/services/ForegroundLocationPermission.js.map +1 -1
  48. package/build/services/GestureDotsService.d.ts.map +1 -1
  49. package/build/services/GestureDotsService.js +2 -1
  50. package/build/services/GestureDotsService.js.map +1 -1
  51. package/build/services/GlassesTouchInput.d.ts +6 -4
  52. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  53. package/build/services/GlassesTouchInput.js +81 -41
  54. package/build/services/GlassesTouchInput.js.map +1 -1
  55. package/build/services/LocalDisplayManager.d.ts +12 -4
  56. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  57. package/build/services/LocalDisplayManager.js +21 -7
  58. package/build/services/LocalDisplayManager.js.map +1 -1
  59. package/build/services/LocalLlmModelManager.d.ts +3 -1
  60. package/build/services/LocalLlmModelManager.d.ts.map +1 -1
  61. package/build/services/LocalLlmModelManager.js +13 -5
  62. package/build/services/LocalLlmModelManager.js.map +1 -1
  63. package/build/services/LocalLlmPrompt.d.ts +10 -9
  64. package/build/services/LocalLlmPrompt.d.ts.map +1 -1
  65. package/build/services/LocalLlmPrompt.js +43 -32
  66. package/build/services/LocalLlmPrompt.js.map +1 -1
  67. package/build/services/LocalLlmProvider.d.ts +1 -0
  68. package/build/services/LocalLlmProvider.d.ts.map +1 -1
  69. package/build/services/LocalLlmProvider.js +62 -22
  70. package/build/services/LocalLlmProvider.js.map +1 -1
  71. package/build/services/LocalMiniappRuntime.d.ts +28 -6
  72. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  73. package/build/services/LocalMiniappRuntime.js +190 -148
  74. package/build/services/LocalMiniappRuntime.js.map +1 -1
  75. package/build/services/MemoryStore.d.ts +13 -1
  76. package/build/services/MemoryStore.d.ts.map +1 -1
  77. package/build/services/MemoryStore.js +98 -1
  78. package/build/services/MemoryStore.js.map +1 -1
  79. package/build/services/MiniappExitGesture.d.ts +6 -3
  80. package/build/services/MiniappExitGesture.d.ts.map +1 -1
  81. package/build/services/MiniappExitGesture.js +4 -4
  82. package/build/services/MiniappExitGesture.js.map +1 -1
  83. package/build/services/MiniappWidgetMode.d.ts +6 -4
  84. package/build/services/MiniappWidgetMode.d.ts.map +1 -1
  85. package/build/services/MiniappWidgetMode.js +7 -6
  86. package/build/services/MiniappWidgetMode.js.map +1 -1
  87. package/build/services/ProviderKeyValidation.d.ts +2 -0
  88. package/build/services/ProviderKeyValidation.d.ts.map +1 -1
  89. package/build/services/ProviderKeyValidation.js +4 -0
  90. package/build/services/ProviderKeyValidation.js.map +1 -1
  91. package/build/services/SceneRenderer.d.ts.map +1 -1
  92. package/build/services/SceneRenderer.js +3 -1
  93. package/build/services/SceneRenderer.js.map +1 -1
  94. package/build/services/ScreenshotFixture.d.ts +56 -0
  95. package/build/services/ScreenshotFixture.d.ts.map +1 -0
  96. package/build/services/ScreenshotFixture.js +72 -0
  97. package/build/services/ScreenshotFixture.js.map +1 -0
  98. package/build/services/actionInvoke.d.ts +37 -0
  99. package/build/services/actionInvoke.d.ts.map +1 -0
  100. package/build/services/actionInvoke.js +89 -0
  101. package/build/services/actionInvoke.js.map +1 -0
  102. package/build/services/appleWatch/AppleWatchLink.d.ts +42 -0
  103. package/build/services/appleWatch/AppleWatchLink.d.ts.map +1 -0
  104. package/build/services/appleWatch/AppleWatchLink.js +259 -0
  105. package/build/services/appleWatch/AppleWatchLink.js.map +1 -0
  106. package/build/services/controllers.d.ts.map +1 -1
  107. package/build/services/controllers.js +16 -0
  108. package/build/services/controllers.js.map +1 -1
  109. package/build/services/manifestActions.d.ts +4 -2
  110. package/build/services/manifestActions.d.ts.map +1 -1
  111. package/build/services/manifestActions.js +9 -3
  112. package/build/services/manifestActions.js.map +1 -1
  113. package/build/services/memoryBackupFile.d.ts +9 -0
  114. package/build/services/memoryBackupFile.d.ts.map +1 -0
  115. package/build/services/memoryBackupFile.js +47 -0
  116. package/build/services/memoryBackupFile.js.map +1 -0
  117. package/build/services/settingsBridge.d.ts +4 -4
  118. package/build/services/settingsBridge.d.ts.map +1 -1
  119. package/build/services/settingsBridge.js +9 -4
  120. package/build/services/settingsBridge.js.map +1 -1
  121. package/build/services/widgetPlacements.d.ts +8 -7
  122. package/build/services/widgetPlacements.d.ts.map +1 -1
  123. package/build/services/widgetPlacements.js +7 -6
  124. package/build/services/widgetPlacements.js.map +1 -1
  125. package/build/stores/apps.d.ts +6 -0
  126. package/build/stores/apps.d.ts.map +1 -1
  127. package/build/stores/apps.js +29 -12
  128. package/build/stores/apps.js.map +1 -1
  129. package/build/stores/bluetoothSettingKeys.d.ts +2 -0
  130. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  131. package/build/stores/bluetoothSettingKeys.js +17 -0
  132. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  133. package/build/stores/settings.d.ts.map +1 -1
  134. package/build/stores/settings.js +42 -2
  135. package/build/stores/settings.js.map +1 -1
  136. package/build/types/applet.d.ts +5 -1
  137. package/build/types/applet.d.ts.map +1 -1
  138. package/build/types/applet.js.map +1 -1
  139. package/build/types/capabilities/rayneo-io.d.ts.map +1 -1
  140. package/build/types/capabilities/rayneo-io.js +2 -0
  141. package/build/types/capabilities/rayneo-io.js.map +1 -1
  142. package/build/types/enums.d.ts +2 -1
  143. package/build/types/enums.d.ts.map +1 -1
  144. package/build/types/enums.js +1 -0
  145. package/build/types/enums.js.map +1 -1
  146. package/build/utils/cloudClient/RnUdpAdapter.d.ts.map +1 -1
  147. package/build/utils/cloudClient/RnUdpAdapter.js +13 -2
  148. package/build/utils/cloudClient/RnUdpAdapter.js.map +1 -1
  149. package/build/utils/display/scene/index.d.ts +1 -1
  150. package/build/utils/display/scene/index.d.ts.map +1 -1
  151. package/build/utils/display/scene/index.js.map +1 -1
  152. package/build/utils/display/scene/process.d.ts.map +1 -1
  153. package/build/utils/display/scene/process.js +20 -1
  154. package/build/utils/display/scene/process.js.map +1 -1
  155. package/build/utils/display/scene/types.d.ts +1 -1
  156. package/build/utils/display/scene/types.d.ts.map +1 -1
  157. package/build/utils/display/scene/types.js.map +1 -1
  158. package/build/utils/miniappGlobals.d.ts +6 -0
  159. package/build/utils/miniappGlobals.d.ts.map +1 -1
  160. package/build/utils/miniappGlobals.js +28 -4
  161. package/build/utils/miniappGlobals.js.map +1 -1
  162. package/package.json +9 -9
  163. package/src/engine.ts +13 -1
  164. package/src/facades/ai.ts +3 -3
  165. package/src/facades/glasses.ts +65 -42
  166. package/src/facades/memory.ts +11 -2
  167. package/src/facades/miniapps.ts +3 -0
  168. package/src/facades/pairing.ts +4 -2
  169. package/src/facades/providerKeys.ts +6 -1
  170. package/src/generated/releaseMetadata.ts +4 -4
  171. package/src/services/AiTierResolution.ts +9 -3
  172. package/src/services/AppRegistry.ts +18 -3
  173. package/src/services/BlobStore.ts +3 -3
  174. package/src/services/BluetoothRestoreLaunch.ts +37 -0
  175. package/src/services/ForegroundLocationPermission.ts +1 -1
  176. package/src/services/GestureDotsService.ts +2 -1
  177. package/src/services/GlassesTouchInput.ts +86 -38
  178. package/src/services/LocalDisplayManager.ts +22 -8
  179. package/src/services/LocalLlmModelManager.ts +16 -6
  180. package/src/services/LocalLlmPrompt.ts +56 -37
  181. package/src/services/LocalLlmProvider.ts +74 -24
  182. package/src/services/LocalMiniappRuntime.ts +228 -147
  183. package/src/services/MemoryStore.ts +111 -2
  184. package/src/services/MiniappExitGesture.ts +8 -5
  185. package/src/services/MiniappWidgetMode.ts +9 -7
  186. package/src/services/ProviderKeyValidation.ts +5 -0
  187. package/src/services/SceneRenderer.ts +3 -1
  188. package/src/services/ScreenshotFixture.ts +113 -0
  189. package/src/services/actionInvoke.ts +105 -0
  190. package/src/services/appleWatch/AppleWatchLink.ts +297 -0
  191. package/src/services/controllers.ts +17 -0
  192. package/src/services/manifestActions.ts +16 -4
  193. package/src/services/memoryBackupFile.ts +50 -0
  194. package/src/services/settingsBridge.ts +9 -4
  195. package/src/services/widgetPlacements.ts +23 -9
  196. package/src/stores/apps.ts +35 -11
  197. package/src/stores/bluetoothSettingKeys.ts +20 -0
  198. package/src/stores/settings.ts +44 -2
  199. package/src/types/applet.ts +5 -1
  200. package/src/types/capabilities/rayneo-io.ts +2 -0
  201. package/src/types/enums.ts +1 -0
  202. package/src/utils/cloudClient/RnUdpAdapter.ts +13 -1
  203. package/src/utils/display/scene/index.ts +1 -0
  204. package/src/utils/display/scene/process.ts +17 -1
  205. package/src/utils/display/scene/types.ts +1 -0
  206. package/src/utils/miniappGlobals.ts +30 -4
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The Apple Watch remote's phone half: the watch app's status context (glasses, the miniapp in
3
+ * front, launchable miniapps, whether Assist takes questions, the mirror setting), mirror frames
4
+ * while the setting is on, and the commands the watch sends back. Native adds whether the active
5
+ * watch is paired and keeps the newest frame for when the watch app opens. Gestures ride
6
+ * `touch_event` like any controller's.
7
+ */
8
+ import type {AppleWatchCommandEvent} from "@glassly/bluetooth-sdk"
9
+ import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
10
+ import type {MirrorSnapshot} from "@glassly/miniapp"
11
+ import {MiniappStreamType} from "@glassly/miniapp"
12
+ import {Platform} from "react-native"
13
+
14
+ import {useAppStatusStore} from "../../stores/apps"
15
+ import {useGlassesStore} from "../../stores/glasses"
16
+ import {SETTINGS, useSettingsStore} from "../../stores/settings"
17
+ import type {ClientApp} from "../../types"
18
+ import {BgTimer} from "../../utils/timers"
19
+ import {isGlassesConnected} from "../GlassesReadiness"
20
+ import localMiniappRuntime from "../LocalMiniappRuntime"
21
+ import {mirrorService} from "../MirrorService"
22
+ import {keyEventsForTextChange} from "../neuralBand/NeuralBandInput"
23
+
24
+ export const ASSIST_PACKAGE = "com.glassly.assistant"
25
+ export const ASSIST_ACTION = "ask"
26
+ /** WatchConnectivity messages top out near 64 KB; frames past this are skipped, not truncated. */
27
+ const MAX_FRAME_BYTES = 48_000
28
+ const MAX_IMAGE_CHARS = 16_000
29
+ const MAX_APPS = 24
30
+ const STATE_DEBOUNCE_MS = 250
31
+ const MIRROR_INTERVAL_MS = 500
32
+
33
+ export interface WatchStateInput {
34
+ glasses: {connected: boolean; model: string; battery: number}
35
+ apps: readonly ClientApp[]
36
+ assist: boolean
37
+ mirror: boolean
38
+ }
39
+
40
+ /** Miniapps the watch can open: shown, compatible, and drawing on the glasses. */
41
+ export function launchableApps(apps: readonly ClientApp[]): {packageName: string; name: string}[] {
42
+ return apps
43
+ .filter((app) => app.type === "standard" && !app.hidden && app.compatibility?.isCompatible !== false)
44
+ .filter((app) => app.packageName && !app.packageName.includes("@empty"))
45
+ .slice(0, MAX_APPS)
46
+ .map((app) => ({packageName: app.packageName, name: app.name}))
47
+ }
48
+
49
+ export function watchState({glasses, apps, assist, mirror}: WatchStateInput): Record<string, unknown> {
50
+ const front = apps.find((app) => app.foregrounded && app.running && app.type === "standard")
51
+ const state: Record<string, unknown> = {
52
+ glasses: {connected: glasses.connected, name: glasses.model || "Glasses", battery: glasses.battery},
53
+ apps: launchableApps(apps),
54
+ assist,
55
+ mirror,
56
+ }
57
+ if (front) state.app = {packageName: front.packageName, name: front.name}
58
+ return state
59
+ }
60
+
61
+ type WatchElement = Record<string, string | number | boolean | number[]>
62
+
63
+ const level = (color: unknown, fallback = 15) =>
64
+ typeof color === "number" ? Math.max(0, Math.min(15, color)) : fallback
65
+
66
+ /** The glasses frame in the watch's drawing vocabulary; null when there is nothing to draw. */
67
+ export function mirrorFrame(snapshot: MirrorSnapshot): Record<string, unknown> | null {
68
+ const canvas = snapshot.canvas
69
+ if (!canvas) return null
70
+ const content = snapshot.content
71
+ if (content.kind === "bitmap") {
72
+ if (content.data.length > MAX_FRAME_BYTES) return null
73
+ return {width: canvas.width, height: canvas.height, bitmap: content.data, elements: []}
74
+ }
75
+ if (content.kind !== "scene") return null
76
+ const elements: WatchElement[] = []
77
+ for (const element of content.elements) {
78
+ const {x, y, w, h} = element.box
79
+ const style = element.style ?? {}
80
+ switch (element.type) {
81
+ case "text":
82
+ if (!element.text) break
83
+ elements.push({
84
+ type: "text",
85
+ x,
86
+ y,
87
+ w,
88
+ h,
89
+ text: element.text,
90
+ size: style.fontSize ?? 20,
91
+ color: level(style.color),
92
+ })
93
+ break
94
+ case "rect":
95
+ elements.push({
96
+ type: "rect",
97
+ x,
98
+ y,
99
+ w,
100
+ h,
101
+ fill: style.fill === true,
102
+ border: style.border ?? 1,
103
+ radius: style.radius ?? 0,
104
+ color: level(style.color),
105
+ })
106
+ break
107
+ case "circle":
108
+ elements.push({
109
+ type: "circle",
110
+ x,
111
+ y,
112
+ w,
113
+ h,
114
+ fill: style.fill === true,
115
+ border: style.border ?? 1,
116
+ color: level(style.color),
117
+ })
118
+ break
119
+ case "line":
120
+ case "triangle":
121
+ case "quad": {
122
+ const points = (element.points ?? []).flatMap((point) => [point.x, point.y])
123
+ if (points.length < 4) break
124
+ const closed = element.type !== "line"
125
+ elements.push({
126
+ type: "line",
127
+ points,
128
+ width: style.width ?? style.border ?? 1,
129
+ color: level(style.color),
130
+ closed,
131
+ fill: closed && style.fill === true,
132
+ })
133
+ break
134
+ }
135
+ case "image":
136
+ if (element.data && element.data.length <= MAX_IMAGE_CHARS)
137
+ elements.push({type: "image", x, y, w, h, data: element.data})
138
+ break
139
+ }
140
+ }
141
+ const frame = {width: canvas.width, height: canvas.height, elements}
142
+ return JSON.stringify(frame).length > MAX_FRAME_BYTES ? null : frame
143
+ }
144
+
145
+ export interface AppleWatchCommandDeps {
146
+ apps: () => readonly ClientApp[]
147
+ start: (app: ClientApp) => Promise<unknown>
148
+ stop: (packageName: string) => Promise<unknown>
149
+ ask: (query: string) => Promise<unknown>
150
+ type: (stream: string, payload: unknown) => void
151
+ now: () => number
152
+ }
153
+
154
+ const defaultDeps: AppleWatchCommandDeps = {
155
+ apps: () => useAppStatusStore.getState().apps,
156
+ start: (app) => useAppStatusStore.getState().start(app),
157
+ stop: (packageName) => useAppStatusStore.getState().stop(packageName),
158
+ ask: (query) => localMiniappRuntime.invokeActionFromHost(ASSIST_PACKAGE, ASSIST_ACTION, {query}),
159
+ type: (stream, payload) => localMiniappRuntime.forwardEvent(stream, payload),
160
+ now: () => Date.now(),
161
+ }
162
+
163
+ export async function handleAppleWatchCommand(
164
+ event: AppleWatchCommandEvent,
165
+ deps: AppleWatchCommandDeps = defaultDeps,
166
+ ): Promise<void> {
167
+ switch (event.command) {
168
+ case "launch": {
169
+ const app = launchableApps(deps.apps()).some((entry) => entry.packageName === event.packageName)
170
+ ? deps.apps().find((entry) => entry.packageName === event.packageName)
171
+ : undefined
172
+ if (!app) {
173
+ console.warn(`AppleWatch: launch of unknown miniapp ${event.packageName}`)
174
+ return
175
+ }
176
+ await deps.start(app)
177
+ return
178
+ }
179
+ case "stop": {
180
+ const front = deps.apps().find((app) => app.foregrounded && app.running && app.type === "standard")
181
+ if (front) await deps.stop(front.packageName)
182
+ return
183
+ }
184
+ case "assist": {
185
+ const query = event.text.trim()
186
+ if (query) await deps.ask(query)
187
+ return
188
+ }
189
+ case "text":
190
+ for (const key of keyEventsForTextChange("", event.text, deps.now()))
191
+ deps.type(MiniappStreamType.KEYBOARD_EVENT, key)
192
+ return
193
+ }
194
+ }
195
+
196
+ let stops: (() => void)[] = []
197
+ let stopMirror: (() => void) | null = null
198
+
199
+ function currentState(): Record<string, unknown> {
200
+ const glasses = useGlassesStore.getState()
201
+ return watchState({
202
+ glasses: {
203
+ connected: isGlassesConnected(glasses.connection),
204
+ model: glasses.deviceModel,
205
+ battery: glasses.batteryLevel,
206
+ },
207
+ apps: useAppStatusStore.getState().apps,
208
+ assist: localMiniappRuntime.hostActionAvailable(ASSIST_PACKAGE, ASSIST_ACTION),
209
+ mirror: useSettingsStore.getState().getSetting(SETTINGS.apple_watch_mirror.key) === true,
210
+ })
211
+ }
212
+
213
+ function syncMirror(enabled: boolean): void {
214
+ if (!enabled) {
215
+ if (stopMirror) {
216
+ stopMirror()
217
+ stopMirror = null
218
+ void BluetoothSdk.setWatchMirror(null).catch(() => {})
219
+ }
220
+ return
221
+ }
222
+ if (stopMirror) return
223
+ let lastSentAt = 0
224
+ let pending: MirrorSnapshot | null = null
225
+ let timer: ReturnType<typeof BgTimer.setTimeout> | null = null
226
+ const send = (snapshot: MirrorSnapshot) => {
227
+ lastSentAt = Date.now()
228
+ void BluetoothSdk.setWatchMirror(mirrorFrame(snapshot)).catch((err) =>
229
+ console.warn("AppleWatch: mirror push failed:", err),
230
+ )
231
+ }
232
+ const unsubscribe = mirrorService.subscribe((snapshot) => {
233
+ const wait = lastSentAt + MIRROR_INTERVAL_MS - Date.now()
234
+ if (wait <= 0 && !timer) {
235
+ send(snapshot)
236
+ return
237
+ }
238
+ pending = snapshot
239
+ timer ??= BgTimer.setTimeout(
240
+ () => {
241
+ timer = null
242
+ if (pending) send(pending)
243
+ pending = null
244
+ },
245
+ Math.max(wait, 0),
246
+ )
247
+ })
248
+ stopMirror = () => {
249
+ unsubscribe()
250
+ if (timer) BgTimer.clearTimeout(timer)
251
+ timer = null
252
+ }
253
+ }
254
+
255
+ export function startAppleWatchLink(): void {
256
+ if (Platform.OS !== "ios" || stops.length > 0) return
257
+ let lastKey = ""
258
+ let timer: ReturnType<typeof BgTimer.setTimeout> | null = null
259
+ const push = () => {
260
+ timer = null
261
+ const state = currentState()
262
+ const key = JSON.stringify(state)
263
+ if (key === lastKey) return
264
+ lastKey = key
265
+ void BluetoothSdk.setWatchState(state).catch((err) => console.warn("AppleWatch: state push failed:", err))
266
+ }
267
+ const schedule = () => {
268
+ timer ??= BgTimer.setTimeout(push, STATE_DEBOUNCE_MS)
269
+ }
270
+ const mirrorEnabled = () => useSettingsStore.getState().getSetting(SETTINGS.apple_watch_mirror.key) === true
271
+ const commands = BluetoothSdk.addListener("apple_watch_command", (event) => {
272
+ handleAppleWatchCommand(event).catch((err) => console.warn(`AppleWatch: ${event.command} failed:`, err))
273
+ })
274
+ stops = [
275
+ () => commands.remove(),
276
+ useGlassesStore.subscribe(schedule),
277
+ useAppStatusStore.subscribe(schedule),
278
+ useSettingsStore.subscribe(
279
+ (s) => s.getSetting(SETTINGS.apple_watch_mirror.key),
280
+ () => {
281
+ syncMirror(mirrorEnabled())
282
+ schedule()
283
+ },
284
+ ),
285
+ () => {
286
+ if (timer) BgTimer.clearTimeout(timer)
287
+ timer = null
288
+ },
289
+ ]
290
+ syncMirror(mirrorEnabled())
291
+ schedule()
292
+ }
293
+
294
+ export function stopAppleWatchLink(): void {
295
+ stops.splice(0).forEach((stop) => stop())
296
+ syncMirror(false)
297
+ }
@@ -26,9 +26,26 @@ const NEURAL_BAND_SURFACE: InputSurface = {
26
26
  ],
27
27
  }
28
28
 
29
+ /** The Apple Watch trackpad's taps and four-way swipes, and the Digital Crown as the dial. */
30
+ const APPLE_WATCH_SURFACE: InputSurface = {
31
+ type: "swipe2d",
32
+ events: [
33
+ "TAP",
34
+ "DOUBLE_TAP",
35
+ "PRESS_HOLD",
36
+ "SWIPE_UP",
37
+ "SWIPE_DOWN",
38
+ "SWIPE_LEFT",
39
+ "SWIPE_RIGHT",
40
+ "DIAL_UP",
41
+ "DIAL_DOWN",
42
+ ],
43
+ }
44
+
29
45
  // The R1 swipes up/down like the temples it stands in for, so it adds nothing.
30
46
  const INPUT_SURFACES: Readonly<Record<string, readonly InputSurface[]>> = {
31
47
  [ControllerTypes.NEURAL_BAND]: [NEURAL_BAND_SURFACE],
48
+ [ControllerTypes.APPLE_WATCH]: [APPLE_WATCH_SURFACE],
32
49
  [ControllerTypes.R1]: [],
33
50
  }
34
51
 
@@ -79,29 +79,41 @@ export function normalizeManifestActions(raw: unknown): DeclaredAction[] {
79
79
  return out
80
80
  }
81
81
 
82
- const WIDGET_SIZES: readonly DeclaredWidgetSize[] = ["XS", "SM", "MD", "LG"]
82
+ const WIDGET_SIZES: readonly DeclaredWidgetSize[] = ["XS", "XSM", "SM", "MD", "LG"]
83
83
  const isWidgetSize = (value: unknown): value is DeclaredWidgetSize => (WIDGET_SIZES as readonly unknown[]).includes(value)
84
84
 
85
85
  /**
86
86
  * Normalize manifest widgets for installed and dev-sideloaded bundles: an
87
- * entry without an id, a name or a usable size is dropped, repeats of a
87
+ * entry without an id, a name or a usable card size is dropped, repeats of a
88
88
  * size are folded, and a default size the widget does not offer becomes
89
- * its first size.
89
+ * its first size. Listing `EX` makes a widget expandable; it is not a card
90
+ * size. Taps expand it until the wearer turns that off (AppRegistry applies
91
+ * the choice).
90
92
  */
91
93
  export function normalizeManifestWidgets(raw: unknown): DeclaredWidget[] {
92
94
  if (!Array.isArray(raw)) return []
93
95
  const out: DeclaredWidget[] = []
94
96
  const seen = new Set<string>()
95
- for (const w of raw as Array<{id?: unknown; name?: unknown; sizes?: unknown; defaultSize?: unknown}>) {
97
+ for (const w of raw as Array<{
98
+ id?: unknown
99
+ name?: unknown
100
+ sizes?: unknown
101
+ defaultSize?: unknown
102
+ expandable?: unknown
103
+ }>) {
96
104
  if (!w || typeof w.id !== "string" || !w.id || typeof w.name !== "string" || !w.name || seen.has(w.id)) continue
97
105
  const sizes = Array.isArray(w.sizes) ? Array.from(new Set(w.sizes.filter(isWidgetSize))) : []
98
106
  if (sizes.length === 0) continue
99
107
  seen.add(w.id)
108
+ // `expandable: true` is how bundles from before `EX` said the same.
109
+ const expandable = (Array.isArray(w.sizes) && w.sizes.includes("EX")) || w.expandable === true
100
110
  out.push({
101
111
  id: w.id,
102
112
  name: w.name,
103
113
  sizes,
104
114
  defaultSize: isWidgetSize(w.defaultSize) && sizes.includes(w.defaultSize) ? w.defaultSize : sizes[0],
115
+ expandable,
116
+ expandOnTap: expandable,
105
117
  })
106
118
  }
107
119
  return out
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Memory backup files: the share-sheet export and file-picker import behind
3
+ * Settings > Memory and session.memory.exportFile/importFile.
4
+ */
5
+
6
+ import type {MemoryExportResult, MemoryImportResult} from "@glassly/miniapp"
7
+ import {File, Paths} from "expo-file-system"
8
+ import {Platform} from "react-native"
9
+ import Share from "react-native-share"
10
+
11
+ import {MemoryBackupError} from "./MemoryStore"
12
+ import {memoryStore} from "./memoryStoreInstance"
13
+
14
+ // Some Android document providers report .json files as octet-stream.
15
+ const PICK_MIME_TYPES =
16
+ Platform.OS === "android" ? ["application/json", "application/octet-stream"] : ["application/json"]
17
+
18
+ export async function exportMemoryFile(): Promise<MemoryExportResult> {
19
+ const backup = await memoryStore.exportBackup()
20
+ const filename = `glassly-memory-${localDate(backup.exportedAt)}.json`
21
+ const file = new File(Paths.cache, filename)
22
+ file.writeSync(JSON.stringify(backup))
23
+ try {
24
+ await Share.open({url: file.uri, type: "application/json", filename, title: "Glassly memory"})
25
+ return {success: true}
26
+ } catch (error: any) {
27
+ // react-native-share rejects when the user dismisses the sheet.
28
+ if (error?.message?.includes("User did not share")) return {success: false, cancelled: true}
29
+ throw error
30
+ }
31
+ }
32
+
33
+ /** Resolves null when the user cancels the picker. */
34
+ export async function importMemoryFile(): Promise<MemoryImportResult | null> {
35
+ const picked = await File.pickFileAsync({mimeTypes: PICK_MIME_TYPES})
36
+ if (picked.canceled) return null
37
+ let data: unknown
38
+ try {
39
+ data = JSON.parse(await picked.result.text())
40
+ } catch {
41
+ throw new MemoryBackupError("Not a Glassly memory backup")
42
+ }
43
+ return memoryStore.importBackup(data)
44
+ }
45
+
46
+ function localDate(ms: number): string {
47
+ const d = new Date(ms)
48
+ const pad = (n: number) => String(n).padStart(2, "0")
49
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
50
+ }
@@ -6,10 +6,10 @@
6
6
  * be tested without the native import surface the runtime pulls in.
7
7
  *
8
8
  * A dashboard miniapp is the usual consumer: it edits the notification,
9
- * glasses-menu and display-position keys the phone's settings screens used
10
- * to, through the store, so persistence, server sync and the Bluetooth sync
11
- * of `menu_apps` and the position behave as if a phone screen wrote them. A dashboard's own look is not here; it keeps
12
- * that in its storage.
9
+ * glasses-menu, display-position, brightness and microphone keys the phone's
10
+ * settings screens do, through the store, so persistence, server sync and the
11
+ * Bluetooth sync behave as if a phone screen wrote them. A dashboard's own
12
+ * look is not here; it keeps that in its storage.
13
13
  */
14
14
 
15
15
  import type {ControllerStatus, PairedController} from "@glassly/bluetooth-sdk"
@@ -28,6 +28,11 @@ export const SETTINGS_BRIDGE_KEYS: Readonly<Record<string, SettingsBridgeAccess>
28
28
  // Where the glasses draw; each model's steps are its display.positionRange.
29
29
  dashboard_depth: "write",
30
30
  dashboard_height: "write",
31
+ // Device settings the dashboard's glasses Settings page edits.
32
+ brightness: "write",
33
+ auto_brightness: "write",
34
+ // Per paired model: the store resolves the indexed key.
35
+ preferred_mic: "write",
31
36
  twelve_hour_time: "write",
32
37
  menu_apps: "write",
33
38
  notifications_enabled: "write",
@@ -2,12 +2,14 @@
2
2
  * Who placed which widget where: the pure bookkeeping behind the runtime's
3
3
  * widget routing. A placement is one host's box for one provider widget;
4
4
  * a provider is placed while any host holds one of its widgets. Frames are
5
- * kept per widget so a host that places one again gets the last frame
6
- * at once.
5
+ * kept per widget and view so a host that places one again gets the last
6
+ * card and expanded frames at once.
7
7
  */
8
8
 
9
+ export type WidgetView = "card" | "expanded"
10
+
9
11
  export interface WidgetBox {
10
- size: "XS" | "SM" | "MD" | "LG"
12
+ size: "XS" | "XSM" | "SM" | "MD" | "LG"
11
13
  width: number
12
14
  height: number
13
15
  }
@@ -22,8 +24,8 @@ export interface Placement {
22
24
  export interface WidgetPlacements {
23
25
  /** Keyed by `${host}|${provider}/${widgetId}`. */
24
26
  placements: ReadonlyMap<string, Placement>
25
- /** The latest frame per `${provider}/${widgetId}`. */
26
- frames: ReadonlyMap<string, unknown[]>
27
+ /** The latest frame per `${provider}/${widgetId}` and view. */
28
+ frames: ReadonlyMap<string, Readonly<Partial<Record<WidgetView, unknown[]>>>>
27
29
  }
28
30
 
29
31
  export const widgetKey = (providerPackageName: string, widgetId: string): string => `${providerPackageName}/${widgetId}`
@@ -71,14 +73,26 @@ export function dropProvider(state: WidgetPlacements, providerPackageName: strin
71
73
  return placements.size === state.placements.size ? state : {...state, placements}
72
74
  }
73
75
 
74
- export function setFrame(state: WidgetPlacements, providerPackageName: string, widgetId: string, elements: unknown[]): WidgetPlacements {
76
+ export function setFrame(
77
+ state: WidgetPlacements,
78
+ providerPackageName: string,
79
+ widgetId: string,
80
+ elements: unknown[],
81
+ view: WidgetView = "card",
82
+ ): WidgetPlacements {
83
+ const key = widgetKey(providerPackageName, widgetId)
75
84
  const frames = new Map(state.frames)
76
- frames.set(widgetKey(providerPackageName, widgetId), elements)
85
+ frames.set(key, {...state.frames.get(key), [view]: elements})
77
86
  return {...state, frames}
78
87
  }
79
88
 
80
- export function frameOf(state: WidgetPlacements, providerPackageName: string, widgetId: string): unknown[] | null {
81
- return state.frames.get(widgetKey(providerPackageName, widgetId)) ?? null
89
+ export function frameOf(
90
+ state: WidgetPlacements,
91
+ providerPackageName: string,
92
+ widgetId: string,
93
+ view: WidgetView = "card",
94
+ ): unknown[] | null {
95
+ return state.frames.get(widgetKey(providerPackageName, widgetId))?.[view] ?? null
82
96
  }
83
97
 
84
98
  export function placementOf(
@@ -19,7 +19,7 @@ import {DeviceTypes} from "../types/enums"
19
19
  import {getModelCapabilities} from "../types/hardware"
20
20
  import {HardwareCompatibility, type CompatibilityResult} from "../utils/hardware/hardware"
21
21
  import {storage} from "../utils/storage/storage"
22
- import appRegistry, {saveAlwaysOnOverride} from "../services/AppRegistry"
22
+ import appRegistry, {saveAlwaysOnOverride, saveWidgetExpandChoice} from "../services/AppRegistry"
23
23
  import localDisplayManager from "../services/LocalDisplayManager"
24
24
  import {islandNotifications} from "../services/NotificationsEmitter"
25
25
  import sttModelManager from "../services/STTModelManager"
@@ -103,6 +103,12 @@ interface AppStatusState {
103
103
  * off leaves it running. Persists the wearer's choice beside the hidden flag.
104
104
  */
105
105
  setAlwaysOn: (packageName: string, status: boolean) => Promise<void>
106
+ /**
107
+ * Whether a tap on one of the miniapp's dashboard widgets expands it to the
108
+ * full screen. Only widgets that list `EX` expand; false for the rest.
109
+ * Persists the choice and answers the setting now in force.
110
+ */
111
+ setWidgetExpand: (packageName: string, widgetId: string, expandOnTap: boolean) => boolean
106
112
  /** Replace the whole apps array. Hosts use this when their extra source changes. */
107
113
  setApps: (apps: ClientApp[]) => void
108
114
  }
@@ -256,6 +262,7 @@ function projectApps(previousState: AppStatusState, localApps: ClientApp[]): Cli
256
262
  // across both passes.
257
263
  foregrounded: app.packageName === previousState.foregroundedPackage,
258
264
  paused: app.local && app.running && isMiniappsPaused(),
265
+ widgetOnly: !!app.local && app.running && miniappWidgetMode.isWidgetOnly(app.packageName),
259
266
  }
260
267
  const prev = previousByPackage.get(app.packageName)
261
268
  return prev && shallowEqualApp(prev, next) ? prev : next
@@ -362,6 +369,11 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
362
369
  return false
363
370
  }
364
371
 
372
+ // An app running only for its widget is promoted before the host hook:
373
+ // the hook may foreground it, and setForeground sends a widget-only app
374
+ // back here.
375
+ if (app.widgetOnly) miniappWidgetMode.setWidgetOnly(packageName, false)
376
+
365
377
  // Host navigation / open-animation seam. Runs on EVERY accepted open —
366
378
  // including re-opening an already-running app — so the animation always
367
379
  // plays (BuiltInMiniappCatalog.navigateForApp: overlay apps setForeground
@@ -374,13 +386,12 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
374
386
 
375
387
  // If the tapped app is already running, this is a re-open: the nav/foreground
376
388
  // above already drove the animation, and there's nothing to spawn. Short-
377
- // circuit before the spawn pipeline. An app running only for its widget
378
- // is promoted instead: it keeps its context and takes the foreground.
389
+ // circuit before the spawn pipeline. A promoted app keeps its context but
390
+ // still takes the foreground below.
379
391
  if (app.running && !app.widgetOnly) {
380
392
  saveLastOpenTime(packageName)
381
393
  return true
382
394
  }
383
- if (app.widgetOnly) miniappWidgetMode.setWidgetOnly(packageName, false)
384
395
 
385
396
  // Only-one rules: one standard app in the foreground, one dashboard
386
397
  // drawing the overlay. Starting another stops the one running. Widget-only
@@ -495,13 +506,10 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
495
506
  apps: s.apps.map((a) => ({...a, foregrounded: a.packageName === packageName})),
496
507
  }))
497
508
 
498
- // Ensure the JSContext exists. start() is idempotent for an
499
- // already-running app and enforces the foreground-only-one rule for
500
- // standard apps. Fire-and-forget after the flag flip so it doesn't block
501
- // paint.
502
- // if (!app.running) {
503
- // get().start(app)
504
- // }
509
+ // The wearer is in this app now: one running only for its widget becomes
510
+ // a normal run. start() promotes it, stops the standard peer and keeps the
511
+ // context. Fire-and-forget after the flag flip so it doesn't block paint.
512
+ if (app.running && app.widgetOnly) void get().start(app, {skipNavigation: true})
505
513
  },
506
514
 
507
515
  clearForeground: () => {
@@ -585,6 +593,22 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
585
593
  if (status && !app.running) await get().start(app, {skipNavigation: true})
586
594
  },
587
595
 
596
+ setWidgetExpand: (packageName, widgetId, expandOnTap) => {
597
+ const app = get().apps.find((a) => a.packageName === packageName)
598
+ const widget = app?.widgets?.find((w) => w.id === widgetId)
599
+ if (!widget?.expandable) return false
600
+ if (widget.expandOnTap === expandOnTap) return expandOnTap
601
+ saveWidgetExpandChoice(packageName, widgetId, expandOnTap)
602
+ set((s) => ({
603
+ apps: s.apps.map((a) =>
604
+ a.packageName === packageName
605
+ ? {...a, widgets: a.widgets?.map((w) => (w.id === widgetId ? {...w, expandOnTap} : w))}
606
+ : a,
607
+ ),
608
+ }))
609
+ return expandOnTap
610
+ },
611
+
588
612
  setApps: (apps) => set({apps}),
589
613
  }))
590
614
 
@@ -35,3 +35,23 @@ function isMentraLiveDevice(model: string | undefined): boolean {
35
35
  export function getBluetoothSettingKeysForDevice(deviceModel: string | undefined, fullKeys: string[]): string[] {
36
36
  return isMentraLiveDevice(deviceModel) ? MENTRA_LIVE_SETTING_KEYS : fullKeys
37
37
  }
38
+
39
+ /**
40
+ * Glasses Menu entries the firmware's own menu cannot open: Settings is the
41
+ * dashboard's glasses page, which only the dashboard's Apps menu shows.
42
+ */
43
+ const NATIVE_MENU_EXCLUDED_PACKAGES = new Set(["com.glassly.settings"])
44
+ const nativeMenuAppsCache = new WeakMap<object, unknown[]>()
45
+
46
+ /** `menu_apps` as the glasses get it; the same array in gives the same array out, so the change-push stays quiet. */
47
+ export function nativeMenuApps(value: unknown): unknown {
48
+ if (!Array.isArray(value)) return value
49
+ let filtered = nativeMenuAppsCache.get(value)
50
+ if (!filtered) {
51
+ filtered = value.filter(
52
+ (item) => !NATIVE_MENU_EXCLUDED_PACKAGES.has((item as {packageName?: string} | null)?.packageName ?? ""),
53
+ )
54
+ nativeMenuAppsCache.set(value, filtered)
55
+ }
56
+ return filtered
57
+ }