@glassly/engine 0.3.5-dev.745 → 0.3.5-dev.746
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.
- package/build/engine.d.ts +3 -0
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +4 -0
- package/build/engine.js.map +1 -1
- package/build/facades/glasses.d.ts +5 -1
- package/build/facades/glasses.d.ts.map +1 -1
- package/build/facades/glasses.js +5 -0
- package/build/facades/glasses.js.map +1 -1
- package/build/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +19 -4
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +68 -125
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/actionInvoke.d.ts +36 -0
- package/build/services/actionInvoke.d.ts.map +1 -0
- package/build/services/actionInvoke.js +89 -0
- package/build/services/actionInvoke.js.map +1 -0
- package/build/services/appleWatch/AppleWatchLink.d.ts +42 -0
- package/build/services/appleWatch/AppleWatchLink.d.ts.map +1 -0
- package/build/services/appleWatch/AppleWatchLink.js +259 -0
- package/build/services/appleWatch/AppleWatchLink.js.map +1 -0
- package/build/services/controllers.d.ts.map +1 -1
- package/build/services/controllers.js +16 -0
- package/build/services/controllers.js.map +1 -1
- package/build/stores/settings.d.ts.map +1 -1
- package/build/stores/settings.js +9 -0
- package/build/stores/settings.js.map +1 -1
- package/build/types/enums.d.ts +2 -1
- package/build/types/enums.d.ts.map +1 -1
- package/build/types/enums.js +1 -0
- package/build/types/enums.js.map +1 -1
- package/package.json +7 -7
- package/src/engine.ts +4 -0
- package/src/facades/glasses.ts +7 -0
- package/src/generated/releaseMetadata.ts +3 -3
- package/src/services/LocalMiniappRuntime.ts +103 -125
- package/src/services/actionInvoke.ts +104 -0
- package/src/services/appleWatch/AppleWatchLink.ts +297 -0
- package/src/services/controllers.ts +17 -0
- package/src/stores/settings.ts +9 -0
- 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
|
|
package/src/stores/settings.ts
CHANGED
|
@@ -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
|