@glassly/engine 0.3.5-beta.710 → 0.3.5-beta.760

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 (119) hide show
  1. package/build/engine.d.ts +4 -1
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +5 -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 +5 -0
  12. package/build/facades/glasses.js.map +1 -1
  13. package/build/generated/releaseMetadata.js +3 -3
  14. package/build/generated/releaseMetadata.js.map +1 -1
  15. package/build/services/GestureDotsService.d.ts.map +1 -1
  16. package/build/services/GestureDotsService.js +2 -1
  17. package/build/services/GestureDotsService.js.map +1 -1
  18. package/build/services/GlassesTouchInput.d.ts +6 -4
  19. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  20. package/build/services/GlassesTouchInput.js +76 -37
  21. package/build/services/GlassesTouchInput.js.map +1 -1
  22. package/build/services/LocalDisplayManager.d.ts +12 -4
  23. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  24. package/build/services/LocalDisplayManager.js +21 -7
  25. package/build/services/LocalDisplayManager.js.map +1 -1
  26. package/build/services/LocalLlmModelManager.d.ts +3 -1
  27. package/build/services/LocalLlmModelManager.d.ts.map +1 -1
  28. package/build/services/LocalLlmModelManager.js +13 -5
  29. package/build/services/LocalLlmModelManager.js.map +1 -1
  30. package/build/services/LocalLlmPrompt.d.ts +10 -9
  31. package/build/services/LocalLlmPrompt.d.ts.map +1 -1
  32. package/build/services/LocalLlmPrompt.js +43 -32
  33. package/build/services/LocalLlmPrompt.js.map +1 -1
  34. package/build/services/LocalLlmProvider.d.ts.map +1 -1
  35. package/build/services/LocalLlmProvider.js +39 -20
  36. package/build/services/LocalLlmProvider.js.map +1 -1
  37. package/build/services/LocalMiniappRuntime.d.ts +22 -4
  38. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  39. package/build/services/LocalMiniappRuntime.js +108 -134
  40. package/build/services/LocalMiniappRuntime.js.map +1 -1
  41. package/build/services/MiniappExitGesture.d.ts +6 -3
  42. package/build/services/MiniappExitGesture.d.ts.map +1 -1
  43. package/build/services/MiniappExitGesture.js +4 -4
  44. package/build/services/MiniappExitGesture.js.map +1 -1
  45. package/build/services/MiniappWidgetMode.d.ts +6 -4
  46. package/build/services/MiniappWidgetMode.d.ts.map +1 -1
  47. package/build/services/MiniappWidgetMode.js +7 -6
  48. package/build/services/MiniappWidgetMode.js.map +1 -1
  49. package/build/services/SceneRenderer.d.ts.map +1 -1
  50. package/build/services/SceneRenderer.js +3 -1
  51. package/build/services/SceneRenderer.js.map +1 -1
  52. package/build/services/actionInvoke.d.ts +37 -0
  53. package/build/services/actionInvoke.d.ts.map +1 -0
  54. package/build/services/actionInvoke.js +89 -0
  55. package/build/services/actionInvoke.js.map +1 -0
  56. package/build/services/appleWatch/AppleWatchLink.d.ts +42 -0
  57. package/build/services/appleWatch/AppleWatchLink.d.ts.map +1 -0
  58. package/build/services/appleWatch/AppleWatchLink.js +259 -0
  59. package/build/services/appleWatch/AppleWatchLink.js.map +1 -0
  60. package/build/services/controllers.d.ts.map +1 -1
  61. package/build/services/controllers.js +16 -0
  62. package/build/services/controllers.js.map +1 -1
  63. package/build/services/settingsBridge.d.ts +4 -4
  64. package/build/services/settingsBridge.d.ts.map +1 -1
  65. package/build/services/settingsBridge.js +9 -4
  66. package/build/services/settingsBridge.js.map +1 -1
  67. package/build/stores/apps.d.ts.map +1 -1
  68. package/build/stores/apps.js +13 -11
  69. package/build/stores/apps.js.map +1 -1
  70. package/build/stores/bluetoothSettingKeys.d.ts +2 -0
  71. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  72. package/build/stores/bluetoothSettingKeys.js +17 -0
  73. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  74. package/build/stores/settings.d.ts.map +1 -1
  75. package/build/stores/settings.js +32 -2
  76. package/build/stores/settings.js.map +1 -1
  77. package/build/types/capabilities/rayneo-io.d.ts.map +1 -1
  78. package/build/types/capabilities/rayneo-io.js +2 -0
  79. package/build/types/capabilities/rayneo-io.js.map +1 -1
  80. package/build/types/enums.d.ts +2 -1
  81. package/build/types/enums.d.ts.map +1 -1
  82. package/build/types/enums.js +1 -0
  83. package/build/types/enums.js.map +1 -1
  84. package/build/utils/display/scene/index.d.ts +1 -1
  85. package/build/utils/display/scene/index.d.ts.map +1 -1
  86. package/build/utils/display/scene/index.js.map +1 -1
  87. package/build/utils/display/scene/process.d.ts.map +1 -1
  88. package/build/utils/display/scene/process.js +20 -1
  89. package/build/utils/display/scene/process.js.map +1 -1
  90. package/build/utils/display/scene/types.d.ts +1 -1
  91. package/build/utils/display/scene/types.d.ts.map +1 -1
  92. package/build/utils/display/scene/types.js.map +1 -1
  93. package/package.json +7 -7
  94. package/src/engine.ts +5 -1
  95. package/src/facades/ai.ts +3 -3
  96. package/src/facades/glasses.ts +7 -0
  97. package/src/generated/releaseMetadata.ts +3 -3
  98. package/src/services/GestureDotsService.ts +2 -1
  99. package/src/services/GlassesTouchInput.ts +81 -34
  100. package/src/services/LocalDisplayManager.ts +22 -8
  101. package/src/services/LocalLlmModelManager.ts +16 -6
  102. package/src/services/LocalLlmPrompt.ts +56 -37
  103. package/src/services/LocalLlmProvider.ts +49 -22
  104. package/src/services/LocalMiniappRuntime.ts +140 -134
  105. package/src/services/MiniappExitGesture.ts +8 -5
  106. package/src/services/MiniappWidgetMode.ts +9 -7
  107. package/src/services/SceneRenderer.ts +3 -1
  108. package/src/services/actionInvoke.ts +105 -0
  109. package/src/services/appleWatch/AppleWatchLink.ts +297 -0
  110. package/src/services/controllers.ts +17 -0
  111. package/src/services/settingsBridge.ts +9 -4
  112. package/src/stores/apps.ts +12 -10
  113. package/src/stores/bluetoothSettingKeys.ts +20 -0
  114. package/src/stores/settings.ts +34 -2
  115. package/src/types/capabilities/rayneo-io.ts +2 -0
  116. package/src/types/enums.ts +1 -0
  117. package/src/utils/display/scene/index.ts +1 -0
  118. package/src/utils/display/scene/process.ts +17 -1
  119. package/src/utils/display/scene/types.ts +1 -0
@@ -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
 
@@ -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",
@@ -256,6 +256,7 @@ function projectApps(previousState: AppStatusState, localApps: ClientApp[]): Cli
256
256
  // across both passes.
257
257
  foregrounded: app.packageName === previousState.foregroundedPackage,
258
258
  paused: app.local && app.running && isMiniappsPaused(),
259
+ widgetOnly: !!app.local && app.running && miniappWidgetMode.isWidgetOnly(app.packageName),
259
260
  }
260
261
  const prev = previousByPackage.get(app.packageName)
261
262
  return prev && shallowEqualApp(prev, next) ? prev : next
@@ -362,6 +363,11 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
362
363
  return false
363
364
  }
364
365
 
366
+ // An app running only for its widget is promoted before the host hook:
367
+ // the hook may foreground it, and setForeground sends a widget-only app
368
+ // back here.
369
+ if (app.widgetOnly) miniappWidgetMode.setWidgetOnly(packageName, false)
370
+
365
371
  // Host navigation / open-animation seam. Runs on EVERY accepted open —
366
372
  // including re-opening an already-running app — so the animation always
367
373
  // plays (BuiltInMiniappCatalog.navigateForApp: overlay apps setForeground
@@ -374,13 +380,12 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
374
380
 
375
381
  // If the tapped app is already running, this is a re-open: the nav/foreground
376
382
  // 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.
383
+ // circuit before the spawn pipeline. A promoted app keeps its context but
384
+ // still takes the foreground below.
379
385
  if (app.running && !app.widgetOnly) {
380
386
  saveLastOpenTime(packageName)
381
387
  return true
382
388
  }
383
- if (app.widgetOnly) miniappWidgetMode.setWidgetOnly(packageName, false)
384
389
 
385
390
  // Only-one rules: one standard app in the foreground, one dashboard
386
391
  // drawing the overlay. Starting another stops the one running. Widget-only
@@ -495,13 +500,10 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
495
500
  apps: s.apps.map((a) => ({...a, foregrounded: a.packageName === packageName})),
496
501
  }))
497
502
 
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
- // }
503
+ // The wearer is in this app now: one running only for its widget becomes
504
+ // a normal run. start() promotes it, stops the standard peer and keeps the
505
+ // context. Fire-and-forget after the flag flip so it doesn't block paint.
506
+ if (app.running && app.widgetOnly) void get().start(app, {skipNavigation: true})
505
507
  },
506
508
 
507
509
  clearForeground: () => {
@@ -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
+ }
@@ -4,7 +4,7 @@ import {create} from "zustand"
4
4
  import {subscribeWithSelector} from "zustand/middleware"
5
5
 
6
6
  import {storage} from "../utils/storage"
7
- import {getBluetoothSettingKeysForDevice} from "./bluetoothSettingKeys"
7
+ import {getBluetoothSettingKeysForDevice, nativeMenuApps} from "./bluetoothSettingKeys"
8
8
  import {useGlassesStore} from "./glasses"
9
9
 
10
10
  export {MENTRA_LIVE_SETTING_KEYS, getBluetoothSettingKeysForDevice} from "./bluetoothSettingKeys"
@@ -831,6 +831,15 @@ export const SETTINGS: Record<string, Setting> = {
831
831
  saveOnServer: false,
832
832
  persist: true,
833
833
  },
834
+ // Apple Watch: draw the glasses' current frame behind the watch trackpad. Frames
835
+ // cross WatchConnectivity while the watch app is up, so it stays off unless asked for.
836
+ apple_watch_mirror: {
837
+ key: "apple_watch_mirror",
838
+ defaultValue: () => false,
839
+ writable: true,
840
+ saveOnServer: false,
841
+ persist: true,
842
+ },
834
843
  // Maximum rate at which display updates leave the phone for the glasses.
835
844
  // The Bluetooth SDK paces every scene frame and legacy layout to this,
836
845
  // coalescing faster updates last-wins; clears bypass it. No effect on G2
@@ -1577,6 +1586,29 @@ export const useSettingsStore = create<SettingsState>()(
1577
1586
  // desired value was applied so a transient failure retries on boot.
1578
1587
  if (migrationSucceeded) storage.save(CAMERA_FOV_MIGRATION_KEY, true)
1579
1588
  }
1589
+
1590
+ // Settings joined the Glasses Menu (the dashboard's glasses Settings
1591
+ // page): add it once, last, to a menu the wearer already saved. An
1592
+ // unsaved menu gets it from the default; a full one (10) is left alone.
1593
+ const MENU_SETTINGS_MIGRATION_KEY = "migration:menu_apps_settings_v1"
1594
+ const menuSettingsMigrationDone = storage.load<boolean>(MENU_SETTINGS_MIGRATION_KEY)
1595
+ if (menuSettingsMigrationDone.is_error() || !menuSettingsMigrationDone.value) {
1596
+ const menu = get().getSetting(SETTINGS.menu_apps.key) as Array<{packageName?: string}> | null
1597
+ let menuMigrated = true
1598
+ if (
1599
+ Array.isArray(menu) &&
1600
+ menu.length < 10 &&
1601
+ !menu.some((item) => item?.packageName === "com.glassly.settings")
1602
+ ) {
1603
+ const next = [...menu, {packageName: "com.glassly.settings", name: "Settings"}]
1604
+ const result = await get().setSetting(SETTINGS.menu_apps.key, next, true)
1605
+ if (result.is_error()) {
1606
+ console.log("SETTINGS: menu_apps Settings migration failed:", result.error)
1607
+ menuMigrated = false
1608
+ }
1609
+ }
1610
+ if (menuMigrated) storage.save(MENU_SETTINGS_MIGRATION_KEY, true)
1611
+ }
1580
1612
  })
1581
1613
  loadAllSettingsInFlight = inFlight
1582
1614
  void Promise.resolve(inFlight).then((result) => {
@@ -1598,7 +1630,7 @@ export const useSettingsStore = create<SettingsState>()(
1598
1630
  if (key === SETTINGS.core_token.key && (typeof value !== "string" || value.trim().length === 0)) {
1599
1631
  continue
1600
1632
  }
1601
- bluetoothSettings[key] = value
1633
+ bluetoothSettings[key] = key === SETTINGS.menu_apps.key ? nativeMenuApps(value) : value
1602
1634
  }
1603
1635
  return bluetoothSettings
1604
1636
  },
@@ -32,6 +32,8 @@ export const rayneoIO: Capabilities = {
32
32
  fieldOfView: {horizontal: 30},
33
33
  maxTextLines: 5,
34
34
  adjustBrightness: true,
35
+ // Both drivers map these settings onto the launcher's three heights and two distances.
36
+ positionRange: {height: {min: 0, max: 8}, depth: {min: 1, max: 3}},
35
37
 
36
38
  // Scene display API — the Glassly CFW page canvas.
37
39
  width: 540,
@@ -18,4 +18,5 @@ export enum DeviceTypes {
18
18
  export enum ControllerTypes {
19
19
  R1 = "Even Realities R1",
20
20
  NEURAL_BAND = "Meta Neural Band",
21
+ APPLE_WATCH = "Apple Watch",
21
22
  }
@@ -17,6 +17,7 @@ export type {
17
17
  SceneStrokeStyle,
18
18
  SceneTextStyle,
19
19
  SceneTransition,
20
+ SceneTransitionEdge,
20
21
  } from "./types"
21
22
  export {boxesEqual, contentHash, elementContentHash} from "./types"
22
23
  export {resolveAnchors} from "./anchor"
@@ -21,6 +21,7 @@ import type {
21
21
  ScenePoint,
22
22
  SceneTextStyle,
23
23
  SceneTransition,
24
+ SceneTransitionEdge,
24
25
  } from "./types"
25
26
  import {MAX_SCENE_DEPTH, POINT_SHAPE_ARITY, POINT_SHAPE_TYPES, elementContentHash} from "./types"
26
27
 
@@ -66,11 +67,24 @@ function pointsBox(points: ScenePoint[]): SceneBox {
66
67
  return {x, y, w: Math.max(...xs) - x + 1, h: Math.max(...ys) - y + 1}
67
68
  }
68
69
 
70
+ function validEdge(e: unknown): SceneTransitionEdge | undefined {
71
+ if (!e || typeof e !== "object") return undefined
72
+ const dx = Math.round(Number((e as SceneTransitionEdge).dx ?? 0))
73
+ const dy = Math.round(Number((e as SceneTransitionEdge).dy ?? 0))
74
+ if (!isFinite(dx) || !isFinite(dy) || Math.abs(dx) > 1024 || Math.abs(dy) > 1024) return undefined
75
+ return {dx, dy}
76
+ }
77
+
69
78
  function validTransition(t: unknown): SceneTransition | undefined {
70
79
  if (!t || typeof t !== "object") return undefined
71
80
  const {durationMs, easing} = t as SceneTransition
72
81
  if (!isFinite(durationMs) || durationMs <= 0) return undefined
73
- return easing ? {durationMs, easing} : {durationMs}
82
+ const out: SceneTransition = easing ? {durationMs, easing} : {durationMs}
83
+ const enter = validEdge((t as SceneTransition).enter)
84
+ const exit = validEdge((t as SceneTransition).exit)
85
+ if (enter) out.enter = enter
86
+ if (exit) out.exit = exit
87
+ return out
74
88
  }
75
89
 
76
90
  function boxShrunk(orig: SceneBox, clamped: SceneBox): boolean {
@@ -175,6 +189,8 @@ export function processScene(
175
189
 
176
190
  for (const {el, index, box, points} of valid) {
177
191
  const transition = validTransition((el as {transition?: unknown}).transition)
192
+ // Leaving elements only exist to tween away; a device that jumps never shows them.
193
+ if (transition?.exit && !caps.animation) continue
178
194
  const transform: {rotation?: number; pivot?: ScenePoint} = {}
179
195
  if (el.rotation !== undefined || el.pivot !== undefined) {
180
196
  const angle = el.rotation ?? 0
@@ -18,6 +18,7 @@ export type {
18
18
  SceneStrokeStyle,
19
19
  SceneTextStyle,
20
20
  SceneTransition,
21
+ SceneTransitionEdge,
21
22
  } from "@glassly/miniapp/display"
22
23
  export {
23
24
  boxesEqual,