@glassly/engine 0.3.5-beta.734 → 0.3.5-beta.767
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/BlobStore.js +3 -3
- package/build/services/BlobStore.js.map +1 -1
- package/build/services/ForegroundLocationPermission.d.ts +1 -1
- package/build/services/ForegroundLocationPermission.d.ts.map +1 -1
- package/build/services/ForegroundLocationPermission.js.map +1 -1
- package/build/services/GestureDotsService.d.ts.map +1 -1
- package/build/services/GestureDotsService.js +2 -1
- package/build/services/GestureDotsService.js.map +1 -1
- package/build/services/GlassesTouchInput.d.ts +2 -1
- package/build/services/GlassesTouchInput.d.ts.map +1 -1
- package/build/services/GlassesTouchInput.js +64 -26
- package/build/services/GlassesTouchInput.js.map +1 -1
- package/build/services/LocalLlmModelManager.d.ts +3 -1
- package/build/services/LocalLlmModelManager.d.ts.map +1 -1
- package/build/services/LocalLlmModelManager.js +13 -5
- package/build/services/LocalLlmModelManager.js.map +1 -1
- package/build/services/LocalLlmPrompt.d.ts +10 -9
- package/build/services/LocalLlmPrompt.d.ts.map +1 -1
- package/build/services/LocalLlmPrompt.js +43 -32
- package/build/services/LocalLlmPrompt.js.map +1 -1
- package/build/services/LocalLlmProvider.d.ts.map +1 -1
- package/build/services/LocalLlmProvider.js +39 -20
- package/build/services/LocalLlmProvider.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 +70 -127
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/SceneRenderer.d.ts.map +1 -1
- package/build/services/SceneRenderer.js +3 -1
- package/build/services/SceneRenderer.js.map +1 -1
- package/build/services/actionInvoke.d.ts +37 -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/services/settingsBridge.d.ts +4 -4
- package/build/services/settingsBridge.d.ts.map +1 -1
- package/build/services/settingsBridge.js +9 -4
- package/build/services/settingsBridge.js.map +1 -1
- package/build/stores/bluetoothSettingKeys.d.ts +2 -0
- package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
- package/build/stores/bluetoothSettingKeys.js +17 -0
- package/build/stores/bluetoothSettingKeys.js.map +1 -1
- package/build/stores/settings.d.ts.map +1 -1
- package/build/stores/settings.js +32 -2
- package/build/stores/settings.js.map +1 -1
- package/build/types/capabilities/rayneo-io.d.ts.map +1 -1
- package/build/types/capabilities/rayneo-io.js +2 -0
- package/build/types/capabilities/rayneo-io.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/build/utils/display/scene/index.d.ts +1 -1
- package/build/utils/display/scene/index.d.ts.map +1 -1
- package/build/utils/display/scene/index.js.map +1 -1
- package/build/utils/display/scene/process.d.ts.map +1 -1
- package/build/utils/display/scene/process.js +20 -1
- package/build/utils/display/scene/process.js.map +1 -1
- package/build/utils/display/scene/types.d.ts +1 -1
- package/build/utils/display/scene/types.d.ts.map +1 -1
- package/build/utils/display/scene/types.js.map +1 -1
- package/package.json +9 -9
- package/src/engine.ts +4 -0
- package/src/facades/glasses.ts +7 -0
- package/src/generated/releaseMetadata.ts +3 -3
- package/src/services/BlobStore.ts +3 -3
- package/src/services/ForegroundLocationPermission.ts +1 -1
- package/src/services/GestureDotsService.ts +2 -1
- package/src/services/GlassesTouchInput.ts +69 -23
- package/src/services/LocalLlmModelManager.ts +16 -6
- package/src/services/LocalLlmPrompt.ts +56 -37
- package/src/services/LocalLlmProvider.ts +49 -22
- package/src/services/LocalMiniappRuntime.ts +105 -127
- package/src/services/SceneRenderer.ts +3 -1
- package/src/services/actionInvoke.ts +105 -0
- package/src/services/appleWatch/AppleWatchLink.ts +297 -0
- package/src/services/controllers.ts +17 -0
- package/src/services/settingsBridge.ts +9 -4
- package/src/stores/bluetoothSettingKeys.ts +20 -0
- package/src/stores/settings.ts +34 -2
- package/src/types/capabilities/rayneo-io.ts +2 -0
- package/src/types/enums.ts +1 -0
- package/src/utils/display/scene/index.ts +1 -0
- package/src/utils/display/scene/process.ts +17 -1
- package/src/utils/display/scene/types.ts +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {MiniappErrorCode} from "@glassly/miniapp"
|
|
2
|
+
|
|
3
|
+
import type {ClientApp} from "../types/applet"
|
|
4
|
+
|
|
5
|
+
export type ActionError = {code: string; message: string} & Record<string, unknown>
|
|
6
|
+
export type ActionOutcome = {ok: true; data: unknown} | {ok: false; error: ActionError}
|
|
7
|
+
|
|
8
|
+
export const actionFailure = (code: string, message: string): ActionOutcome => ({ok: false, error: {code, message}})
|
|
9
|
+
|
|
10
|
+
/** Installed, compatible, and declares the action. */
|
|
11
|
+
export function appDeclaresAction(app: ClientApp | undefined, actionId: string): boolean {
|
|
12
|
+
if (!app) return false
|
|
13
|
+
if (app.compatibility && app.compatibility.isCompatible === false) return false
|
|
14
|
+
return (app.actions ?? []).some((a) => a.id === actionId)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gates shared by miniapp->miniapp invoke and host invoke. Declared actions are
|
|
19
|
+
* public by default; `visibility: "system"` ones need a system caller.
|
|
20
|
+
*/
|
|
21
|
+
export function checkActionAccess(
|
|
22
|
+
app: ClientApp | undefined,
|
|
23
|
+
target: string | undefined,
|
|
24
|
+
actionId: string | undefined,
|
|
25
|
+
callerIsSystem: boolean,
|
|
26
|
+
): ActionError | null {
|
|
27
|
+
if (!target || !app) {
|
|
28
|
+
return {
|
|
29
|
+
code: MiniappErrorCode.APP_NOT_FOUND,
|
|
30
|
+
message: `Miniapp not found: ${target ?? "(missing targetPackageName)"}`,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!actionId) return {code: MiniappErrorCode.ACTION_NOT_FOUND, message: "invoke requires an actionId"}
|
|
34
|
+
if (app.compatibility && app.compatibility.isCompatible === false) {
|
|
35
|
+
return {
|
|
36
|
+
code: MiniappErrorCode.APP_NOT_COMPATIBLE,
|
|
37
|
+
message: `${target} is not compatible with the connected glasses`,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const declared = (app.actions ?? []).find((a) => a.id === actionId)
|
|
41
|
+
if (!declared) {
|
|
42
|
+
return {code: MiniappErrorCode.ACTION_NOT_FOUND, message: `${target} does not declare action "${actionId}"`}
|
|
43
|
+
}
|
|
44
|
+
if (declared.visibility === "system" && !callerIsSystem) {
|
|
45
|
+
return {
|
|
46
|
+
code: MiniappErrorCode.NOT_PERMITTED,
|
|
47
|
+
message: `Action "${actionId}" on ${target} is restricted to system apps`,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** BgTimer's shape; passed in so this module stays loadable without the native timer. */
|
|
54
|
+
export interface ActionTimers {
|
|
55
|
+
setTimeout: (cb: () => void, ms: number) => number
|
|
56
|
+
clearTimeout: (id: number) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Outstanding ACTION_CALLs awaiting an ACTION_RESULT, keyed by host-generated callId. */
|
|
60
|
+
export class PendingActionCalls {
|
|
61
|
+
private readonly calls = new Map<string, {target: string; settle: (o: ActionOutcome) => void; timer: number}>()
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
private readonly timers: ActionTimers,
|
|
65
|
+
private readonly payloadTooLarge: (value: unknown) => boolean,
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
/** Register a call; resolves with its outcome, or ACTION_TIMEOUT. */
|
|
69
|
+
register(callId: string, target: string, actionId: string, timeoutMs: number): Promise<ActionOutcome> {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const timer = this.timers.setTimeout(() => {
|
|
72
|
+
this.calls.delete(callId)
|
|
73
|
+
resolve(actionFailure(MiniappErrorCode.ACTION_TIMEOUT, `action "${actionId}" timed out after ${timeoutMs}ms`))
|
|
74
|
+
}, timeoutMs)
|
|
75
|
+
this.calls.set(callId, {target, settle: resolve, timer})
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Settle from a target's ACTION_RESULT payload. Only the invoked target may resolve a call. */
|
|
80
|
+
handleResult(targetPackageName: string, payload: Record<string, unknown>): void {
|
|
81
|
+
const callId = payload.callId as string | undefined
|
|
82
|
+
if (!callId) return
|
|
83
|
+
const pending = this.calls.get(callId)
|
|
84
|
+
// callIds are guessable (timestamp + seq): a different miniapp must not spoof someone else's result.
|
|
85
|
+
if (!pending || pending.target !== targetPackageName) return
|
|
86
|
+
this.calls.delete(callId)
|
|
87
|
+
this.timers.clearTimeout(pending.timer)
|
|
88
|
+
|
|
89
|
+
if (payload.ok === true) {
|
|
90
|
+
if (this.payloadTooLarge(payload.result)) {
|
|
91
|
+
pending.settle(actionFailure(MiniappErrorCode.PAYLOAD_TOO_LARGE, "action result exceeded the 256 KB cap"))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
pending.settle({ok: true, data: payload.result ?? null})
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
pending.settle({
|
|
98
|
+
ok: false,
|
|
99
|
+
error: (payload.error as ActionError | undefined) ?? {
|
|
100
|
+
code: MiniappErrorCode.INTERNAL,
|
|
101
|
+
message: "action handler error",
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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",
|
|
@@ -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
|
+
}
|
package/src/stores/settings.ts
CHANGED
|
@@ -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,
|
package/src/types/enums.ts
CHANGED
|
@@ -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
|
-
|
|
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
|