@glassly/engine 0.3.5-dev.745 → 0.3.5-dev.748

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 (42) hide show
  1. package/build/engine.d.ts +3 -0
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +4 -0
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/glasses.d.ts +5 -1
  6. package/build/facades/glasses.d.ts.map +1 -1
  7. package/build/facades/glasses.js +5 -0
  8. package/build/facades/glasses.js.map +1 -1
  9. package/build/generated/releaseMetadata.js +3 -3
  10. package/build/generated/releaseMetadata.js.map +1 -1
  11. package/build/services/LocalMiniappRuntime.d.ts +19 -4
  12. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  13. package/build/services/LocalMiniappRuntime.js +68 -125
  14. package/build/services/LocalMiniappRuntime.js.map +1 -1
  15. package/build/services/actionInvoke.d.ts +37 -0
  16. package/build/services/actionInvoke.d.ts.map +1 -0
  17. package/build/services/actionInvoke.js +89 -0
  18. package/build/services/actionInvoke.js.map +1 -0
  19. package/build/services/appleWatch/AppleWatchLink.d.ts +42 -0
  20. package/build/services/appleWatch/AppleWatchLink.d.ts.map +1 -0
  21. package/build/services/appleWatch/AppleWatchLink.js +259 -0
  22. package/build/services/appleWatch/AppleWatchLink.js.map +1 -0
  23. package/build/services/controllers.d.ts.map +1 -1
  24. package/build/services/controllers.js +16 -0
  25. package/build/services/controllers.js.map +1 -1
  26. package/build/stores/settings.d.ts.map +1 -1
  27. package/build/stores/settings.js +9 -0
  28. package/build/stores/settings.js.map +1 -1
  29. package/build/types/enums.d.ts +2 -1
  30. package/build/types/enums.d.ts.map +1 -1
  31. package/build/types/enums.js +1 -0
  32. package/build/types/enums.js.map +1 -1
  33. package/package.json +7 -7
  34. package/src/engine.ts +4 -0
  35. package/src/facades/glasses.ts +7 -0
  36. package/src/generated/releaseMetadata.ts +3 -3
  37. package/src/services/LocalMiniappRuntime.ts +103 -125
  38. package/src/services/actionInvoke.ts +105 -0
  39. package/src/services/appleWatch/AppleWatchLink.ts +297 -0
  40. package/src/services/controllers.ts +17 -0
  41. package/src/stores/settings.ts +9 -0
  42. package/src/types/enums.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
 
@@ -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
@@ -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
  }