@glassly/engine 0.3.2-dev.586 → 0.3.2-dev.595
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 +14 -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 +20 -0
- package/build/facades/glasses.d.ts.map +1 -1
- package/build/facades/glasses.js +21 -0
- package/build/facades/glasses.js.map +1 -1
- package/build/facades/pairing.js +1 -1
- package/build/facades/pairing.js.map +1 -1
- package/build/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/services/DeviceEventRouter.d.ts.map +1 -1
- package/build/services/DeviceEventRouter.js +5 -0
- package/build/services/DeviceEventRouter.js.map +1 -1
- package/build/services/GestureDotsService.d.ts +5 -2
- package/build/services/GestureDotsService.d.ts.map +1 -1
- package/build/services/GestureDotsService.js +28 -14
- package/build/services/GestureDotsService.js.map +1 -1
- package/build/services/GlassesTouchInput.d.ts +1 -1
- package/build/services/GlassesTouchInput.d.ts.map +1 -1
- package/build/services/GlassesTouchInput.js +7 -4
- package/build/services/GlassesTouchInput.js.map +1 -1
- package/build/services/gestureDotGlyphs.d.ts +1 -1
- package/build/services/gestureDotGlyphs.d.ts.map +1 -1
- package/build/services/gestureDotGlyphs.js +5 -0
- package/build/services/gestureDotGlyphs.js.map +1 -1
- package/build/services/neuralBand/MetaAuth.d.ts +31 -0
- package/build/services/neuralBand/MetaAuth.d.ts.map +1 -0
- package/build/services/neuralBand/MetaAuth.js +161 -0
- package/build/services/neuralBand/MetaAuth.js.map +1 -0
- package/build/services/neuralBand/NeuralBandInput.d.ts +50 -0
- package/build/services/neuralBand/NeuralBandInput.d.ts.map +1 -0
- package/build/services/neuralBand/NeuralBandInput.js +114 -0
- package/build/services/neuralBand/NeuralBandInput.js.map +1 -0
- package/build/services/neuralBand/NeuralBandSettingsSync.d.ts +3 -0
- package/build/services/neuralBand/NeuralBandSettingsSync.d.ts.map +1 -0
- package/build/services/neuralBand/NeuralBandSettingsSync.js +52 -0
- package/build/services/neuralBand/NeuralBandSettingsSync.js.map +1 -0
- package/build/services/neuralBand/sha256.d.ts +7 -0
- package/build/services/neuralBand/sha256.d.ts.map +1 -0
- package/build/services/neuralBand/sha256.js +68 -0
- package/build/services/neuralBand/sha256.js.map +1 -0
- package/build/stores/settings.d.ts.map +1 -1
- package/build/stores/settings.js +18 -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 +27 -0
- package/src/facades/pairing.ts +1 -1
- package/src/generated/releaseMetadata.ts +3 -3
- package/src/index.ts +14 -0
- package/src/services/DeviceEventRouter.ts +5 -0
- package/src/services/GestureDotsService.ts +28 -15
- package/src/services/GlassesTouchInput.ts +13 -5
- package/src/services/gestureDotGlyphs.ts +6 -1
- package/src/services/neuralBand/MetaAuth.ts +192 -0
- package/src/services/neuralBand/NeuralBandInput.ts +148 -0
- package/src/services/neuralBand/NeuralBandSettingsSync.ts +63 -0
- package/src/services/neuralBand/sha256.ts +70 -0
- package/src/stores/settings.ts +18 -0
- package/src/types/enums.ts +1 -0
package/src/facades/pairing.ts
CHANGED
|
@@ -63,7 +63,7 @@ function projectTargetReady(options: Pick<PairingReadyWaitOptions, "deviceModel"
|
|
|
63
63
|
const {deviceModel, deviceName} = options
|
|
64
64
|
const glasses = useGlassesStore.getState()
|
|
65
65
|
|
|
66
|
-
if (deviceModel
|
|
66
|
+
if (deviceModel !== undefined && (Object.values(ControllerTypes) as string[]).includes(deviceModel)) {
|
|
67
67
|
if (!glasses.controllerConnected || !glasses.controllerFullyBooted) return false
|
|
68
68
|
const settings = useSettingsStore.getState()
|
|
69
69
|
const pairedModel = settings.getSetting(SETTINGS.default_controller.key)
|
|
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
|
|
|
12
12
|
export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "0.3.2",
|
|
15
|
-
"releaseIdentity": "0.3.2-dev.
|
|
16
|
-
"releaseSetId": "glassly-0.3.2-dev.
|
|
17
|
-
"sourceCommit": "
|
|
15
|
+
"releaseIdentity": "0.3.2-dev.595",
|
|
16
|
+
"releaseSetId": "glassly-0.3.2-dev.595",
|
|
17
|
+
"sourceCommit": "fdafc60b7dfa2523c464e2fb8a6347b9975dd8b7",
|
|
18
18
|
"otaManifestUrl": null,
|
|
19
19
|
"otaManifestSha256": null
|
|
20
20
|
})
|
package/src/index.ts
CHANGED
|
@@ -201,6 +201,20 @@ export {
|
|
|
201
201
|
} from "./utils/devMiniappLaunch"
|
|
202
202
|
export {HardwareCompatibility, type CompatibilityResult} from "./utils/hardware"
|
|
203
203
|
export {BgTimer, throttle, debounce} from "./utils/timers"
|
|
204
|
+
// Meta Neural Band: the account sign-in a claim needs (the WebView lives in the host).
|
|
205
|
+
export {
|
|
206
|
+
MetaAuthError,
|
|
207
|
+
authEntryUrl,
|
|
208
|
+
callbackMatches,
|
|
209
|
+
callbackQuery,
|
|
210
|
+
decryptBlob,
|
|
211
|
+
isLoginCallback,
|
|
212
|
+
login,
|
|
213
|
+
tokensQuery,
|
|
214
|
+
type MetaSession,
|
|
215
|
+
type MetaSsoTokens,
|
|
216
|
+
} from "./services/neuralBand/MetaAuth"
|
|
217
|
+
export type {NeuralBandClaimStatus, NeuralBandHandwritingStatus} from "./services/neuralBand/NeuralBandInput"
|
|
204
218
|
|
|
205
219
|
// Hardware types and capability profiles.
|
|
206
220
|
export {
|
|
@@ -35,6 +35,7 @@ import {retirePendingSelectionOnPromotion} from "./PairingIdentity"
|
|
|
35
35
|
import GlobalEventEmitter from "../utils/GlobalEventEmitter"
|
|
36
36
|
import {asgCameraApi} from "./asg/asgCameraApi"
|
|
37
37
|
import {dispatchTouchEvent, resetTouchInput} from "./GlassesTouchInput"
|
|
38
|
+
import neuralBandInput from "./neuralBand/NeuralBandInput"
|
|
38
39
|
import dashboardOpenTrace from "./debug/DashboardOpenTrace"
|
|
39
40
|
|
|
40
41
|
let subs: Array<{remove: () => void}> = []
|
|
@@ -233,6 +234,10 @@ export function startDeviceEventRouter(): void {
|
|
|
233
234
|
// Physical keyboard bridged through G2 Glassly CFW: raw HID transitions →
|
|
234
235
|
// layout-translated keyboard_event stream (with synthesized auto-repeat).
|
|
235
236
|
subs.push(BluetoothSdk.addListener("keyboard_key", (event) => glassesKeyboardInput.handle(event)))
|
|
237
|
+
// Meta Neural Band: its gestures ride touch_event above; these are its own streams.
|
|
238
|
+
subs.push(BluetoothSdk.addListener("neural_band_claim", (event) => neuralBandInput.handleClaim(event)))
|
|
239
|
+
subs.push(BluetoothSdk.addListener("neural_band_handwriting", (event) => neuralBandInput.handleHandwriting(event)))
|
|
240
|
+
subs.push(BluetoothSdk.addListener("neural_band_pointer", (event) => neuralBandInput.handlePointer(event)))
|
|
236
241
|
subs.push({
|
|
237
242
|
remove: useGlassesStore.subscribe(
|
|
238
243
|
(s) => s.keyboardConnected === true,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gesture dots — a debug indicator that flashes a small glyph top-right on the
|
|
3
3
|
* glasses when a touch gesture arrives, while nothing else is on the display:
|
|
4
|
-
* a filled circle for a single tap, an arrow for a swipe
|
|
5
|
-
* short pulse), a bullseye while a CFW tap-then-hold is held.
|
|
4
|
+
* a filled circle for a single tap, two for a double tap, an arrow for a swipe
|
|
5
|
+
* (cleared after a short pulse), a bullseye while a CFW tap-then-hold is held.
|
|
6
|
+
* The Neural Band's middle finger (index 1) draws the same shapes hollow. Extracted from
|
|
6
7
|
* the CFW dashboard service; it renders through
|
|
7
8
|
* the ordinary scene pipeline, so it works on any positioning device (stock
|
|
8
9
|
* G2 included), and is gated by the gesture_dots debug setting.
|
|
@@ -27,6 +28,18 @@ const MARGIN_X = 16
|
|
|
27
28
|
const MARGIN_TOP = 10
|
|
28
29
|
// Off: tap_then_hold opens the side menu now.
|
|
29
30
|
const HOLD_GLYPH_ENABLED = false
|
|
31
|
+
/** Gestures that flash a glyph. */
|
|
32
|
+
const FLASH_GLYPHS: Record<string, GestureDotGlyph> = {
|
|
33
|
+
single_tap: "dot",
|
|
34
|
+
double_tap: "double",
|
|
35
|
+
long_press: "hold",
|
|
36
|
+
swipe_up: "up",
|
|
37
|
+
swipe_down: "down",
|
|
38
|
+
swipe_left: "left",
|
|
39
|
+
swipe_right: "right",
|
|
40
|
+
}
|
|
41
|
+
/** The band's middle finger draws the filled shapes hollow. */
|
|
42
|
+
const MIDDLE_FINGER_GLYPHS: Partial<Record<GestureDotGlyph, GestureDotGlyph>> = {dot: "ring", double: "doubleRing"}
|
|
30
43
|
|
|
31
44
|
class GestureDotsService {
|
|
32
45
|
private started = false
|
|
@@ -34,6 +47,8 @@ class GestureDotsService {
|
|
|
34
47
|
private flashTimer: number | null = null
|
|
35
48
|
/** Finger currently on the touch bar (tap_start..tap_end; the CFW forwards holds as these edges). */
|
|
36
49
|
private held = false
|
|
50
|
+
/** The band's middle finger is down (its edges carry index 1); any other finger draws the disc. */
|
|
51
|
+
private heldMiddle = false
|
|
37
52
|
/**
|
|
38
53
|
* The CFW's tap_then_hold fired during this press (discrete recognition
|
|
39
54
|
* event; the tap_end that follows on release is what clears it).
|
|
@@ -76,6 +91,7 @@ class GestureDotsService {
|
|
|
76
91
|
this.flashTimer = null
|
|
77
92
|
this.flash = null
|
|
78
93
|
this.held = false
|
|
94
|
+
this.heldMiddle = false
|
|
79
95
|
this.holdRecognized = false
|
|
80
96
|
}
|
|
81
97
|
|
|
@@ -94,15 +110,16 @@ class GestureDotsService {
|
|
|
94
110
|
/**
|
|
95
111
|
* Flash a gesture on the indicator. Other gesture names are ignored.
|
|
96
112
|
* Claimed gestures cancel any earlier press/flash without starting a new
|
|
97
|
-
* one: the claimant may also consume the release edge.
|
|
113
|
+
* one: the claimant may also consume the release edge. `fingerIndex` is the
|
|
114
|
+
* Neural Band's finger on its pinches and press edges (1 = middle).
|
|
98
115
|
*/
|
|
99
|
-
noteGesture(gestureName: string, claimed = false): void {
|
|
116
|
+
noteGesture(gestureName: string, claimed = false, fingerIndex?: number): void {
|
|
100
117
|
if (!this.started || !this.enabled()) return
|
|
101
|
-
this.applyGesture(gestureName, claimed)
|
|
118
|
+
this.applyGesture(gestureName, claimed, fingerIndex)
|
|
102
119
|
this.trace("input", {gestureName, claimed})
|
|
103
120
|
}
|
|
104
121
|
|
|
105
|
-
private applyGesture(gestureName: string, claimed: boolean): void {
|
|
122
|
+
private applyGesture(gestureName: string, claimed: boolean, fingerIndex?: number): void {
|
|
106
123
|
if (localDisplayManager.hasActiveDisplay() || useDisplayStore.getState().overlayOwner !== null) {
|
|
107
124
|
this.relinquish()
|
|
108
125
|
return
|
|
@@ -120,6 +137,7 @@ class GestureDotsService {
|
|
|
120
137
|
const held = gestureName !== "tap_end"
|
|
121
138
|
if (this.held === held) return
|
|
122
139
|
this.held = held
|
|
140
|
+
this.heldMiddle = held && fingerIndex === 1
|
|
123
141
|
if (!held) this.holdRecognized = false
|
|
124
142
|
this.render()
|
|
125
143
|
return
|
|
@@ -133,15 +151,10 @@ class GestureDotsService {
|
|
|
133
151
|
// Any other gesture implies the finger is up, even if tap_end was lost.
|
|
134
152
|
const released = this.held
|
|
135
153
|
this.held = false
|
|
154
|
+
this.heldMiddle = false
|
|
136
155
|
this.holdRecognized = false
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
? "dot"
|
|
140
|
-
: gestureName === "swipe_up"
|
|
141
|
-
? "up"
|
|
142
|
-
: gestureName === "swipe_down"
|
|
143
|
-
? "down"
|
|
144
|
-
: null
|
|
156
|
+
const shape = FLASH_GLYPHS[gestureName] ?? null
|
|
157
|
+
const flash = shape && fingerIndex === 1 ? MIDDLE_FINGER_GLYPHS[shape] ?? shape : shape
|
|
145
158
|
if (!flash) {
|
|
146
159
|
if (released) this.render()
|
|
147
160
|
return
|
|
@@ -188,7 +201,7 @@ class GestureDotsService {
|
|
|
188
201
|
private render(): void {
|
|
189
202
|
if (!this.started) return
|
|
190
203
|
if (localDisplayManager.hasActiveDisplay() || useDisplayStore.getState().overlayOwner !== null) return
|
|
191
|
-
const shown = this.flash ?? (this.held ? (this.holdRecognized ? "hold" : "dot") : null)
|
|
204
|
+
const shown = this.flash ?? (this.held ? (this.holdRecognized ? "hold" : this.heldMiddle ? "ring" : "dot") : null)
|
|
192
205
|
if (!this.enabled()) {
|
|
193
206
|
if (this.visible) {
|
|
194
207
|
this.visible = false
|
|
@@ -50,8 +50,11 @@ function shouldForwardScroll(event: TouchEvent): boolean {
|
|
|
50
50
|
export function dispatchTouchEvent(event: TouchEvent): void {
|
|
51
51
|
const gesture = event.gestureName
|
|
52
52
|
const claimant = localMiniappRuntime.gestureClaimant(gesture)
|
|
53
|
+
// The Neural Band's middle finger (index 1) shares the tap names but is left
|
|
54
|
+
// to miniapps: the engine's own double-tap behaviors are the index finger's.
|
|
55
|
+
const osDoubleTap = gesture === "double_tap" && event.index !== 1
|
|
53
56
|
|
|
54
|
-
if (
|
|
57
|
+
if (osDoubleTap) {
|
|
55
58
|
// A visible main miniapp opts out of OS exit by registering double tap (the
|
|
56
59
|
// terminal's back-to-menu). The latest claimant still receives it. Otherwise
|
|
57
60
|
// the tap means "leave the app". The controller shows the bounded OS
|
|
@@ -72,7 +75,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
|
|
|
72
75
|
// An overlay app claims the double tap on an empty screen to open the dashboard.
|
|
73
76
|
if (gesture === "double_tap" && isOverlayApp(claimant)) dashboardOpenTrace.trigger("double-tap", event.timestamp)
|
|
74
77
|
// The press may have started before this app claimed its release edge.
|
|
75
|
-
gestureDotsService.noteGesture(gesture, true)
|
|
78
|
+
gestureDotsService.noteGesture(gesture, true, event.index)
|
|
76
79
|
if (!shouldForwardScroll(event)) return
|
|
77
80
|
localMiniappRuntime.forwardEvent("touch_event", event, undefined, claimant)
|
|
78
81
|
return
|
|
@@ -81,7 +84,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
|
|
|
81
84
|
// Gesture-dots debug indicator (any device, gated by its setting). It
|
|
82
85
|
// only paints while the screen is otherwise empty. Process even suppressed
|
|
83
86
|
// scroll repeats here so their completion still clears a held dot.
|
|
84
|
-
gestureDotsService.noteGesture(gesture)
|
|
87
|
+
gestureDotsService.noteGesture(gesture, false, event.index)
|
|
85
88
|
|
|
86
89
|
if (!shouldForwardScroll(event)) return
|
|
87
90
|
localMiniappRuntime.forwardEvent("touch_event", event)
|
|
@@ -90,7 +93,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
|
|
|
90
93
|
// Glassly CFW glasses the dashboard miniapp claims it while it runs
|
|
91
94
|
// (ADR-0006); stock firmware keeps its native dashboard (ADR-0002). The
|
|
92
95
|
// modal nav UI owns the screen; showDashboard would fight it.
|
|
93
|
-
if (
|
|
96
|
+
if (osDoubleTap && !nativeNavigationService.isActive) {
|
|
94
97
|
useDisplayStore.getState().setDisplayEvent({layout: {layoutType: "unavailable", reason: "firmware-dashboard"}})
|
|
95
98
|
void Promise.resolve(BluetoothSdk.showDashboard()).catch((err) => {
|
|
96
99
|
console.error("DeviceEventRouter: showDashboard failed:", err)
|
|
@@ -113,7 +116,12 @@ export function sendGesture(
|
|
|
113
116
|
| "tap_then_hold"
|
|
114
117
|
| "touch_down"
|
|
115
118
|
| "swipe_up"
|
|
116
|
-
| "swipe_down"
|
|
119
|
+
| "swipe_down"
|
|
120
|
+
| "swipe_left"
|
|
121
|
+
| "swipe_right"
|
|
122
|
+
| "long_press"
|
|
123
|
+
| "dial_up"
|
|
124
|
+
| "dial_down",
|
|
117
125
|
): void {
|
|
118
126
|
const model = useGlassesStore.getState().deviceModel
|
|
119
127
|
dispatchTouchEvent({
|
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const GESTURE_DOT_GLYPH_SIZE = 24
|
|
6
6
|
|
|
7
|
-
export type GestureDotGlyph = "dot" | "up" | "down" | "hold" | "blank"
|
|
7
|
+
export type GestureDotGlyph = "dot" | "double" | "ring" | "doubleRing" | "up" | "down" | "left" | "right" | "hold" | "blank"
|
|
8
8
|
|
|
9
9
|
export const GESTURE_DOT_GLYPHS: Record<GestureDotGlyph, string> = {
|
|
10
10
|
dot: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAALklEQVR4nM2QsQ0AAAjC/P/pmrhqZ+xIF6DqBQyWbwO3gduAmKjQuj7QL/ETEzQmMptl7Q9NBAAAAABJRU5ErkJggg==",
|
|
11
|
+
double: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAAG0lEQVR4nGNgGD7gPxjgYkN4yOyBlcDp3GEBAB3sd4lPaQxDAAAAAElFTkSuQmCC",
|
|
12
|
+
ring: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAANElEQVR4nGNgGBTgPxjgEseUgQphyCD4qDL/kSX+Y9WApoWqEjgsx+lc3B7EHSS4A3EgAAC+cEu1JTNREQAAAABJRU5ErkJggg==",
|
|
13
|
+
doubleRing: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAAIklEQVR4nGNgGD7gPxjgYkN4yGwGmBA6SV0JHJbjdO6wAADBMlep8zl7TAAAAABJRU5ErkJggg==",
|
|
11
14
|
up: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAAMElEQVR4nGNgoCv4////f1zi2GT+/4ciTHE4RhdHIlDFUUhkcTQKQx9WBw8PCVoBAIcbOcf3xeeBAAAAAElFTkSuQmCC",
|
|
12
15
|
down: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAALUlEQVR4nGNgoC/4/3+4SkBFUCkkNjKJIoMg0GRgGEMGgrC4CQSwuxanR2gEAO1/Ocfgp8uvAAAAAElFTkSuQmCC",
|
|
16
|
+
left: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAAI0lEQVR4nGNgoCv4j0sch8RAiTP8RwPESOAwajDI4JKgGQAAKO05x7DqJxIAAAAASUVORK5CYII=",
|
|
17
|
+
right: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAAIElEQVR4nGNgoDf4j1NiMMn8RwOEJYizZKDEcQc7bQAAS7w5x+ro2lsAAAAASUVORK5CYII=",
|
|
13
18
|
hold: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAAR0lEQVR4nGNgIAj+gwF2UUy5//+xyyDzcLGRedjMhTEwXAKl0Z0Lof7DKbgMhBpQCZzOxedBHEGCMxAJBTXhKGRAA9hFMQEANr7jHQU9fO0AAAAASUVORK5CYII=",
|
|
14
19
|
blank: "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAAD0lEQVR4nGNgGAWjgPoAAAJYAAGgxYwDAAAAAElFTkSuQmCC",
|
|
15
20
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Meta account sign-in a Neural Band claim needs, ported from callbacked/kinesis
|
|
3
|
+
* (MIT, Copyright (c) 2026 callbacked): Sources/Kinesis/MetaAuth.swift.
|
|
4
|
+
*
|
|
5
|
+
* The chain: an anonymous tokens query, a WebView sign-in on auth.meta.com whose
|
|
6
|
+
* fb-viewapp://…frl_login redirect carries a token and a blob, a blob decrypt, and
|
|
7
|
+
* the ar-genai exchange that yields the user session. The session then goes to the
|
|
8
|
+
* native Bluetooth SDK (keychain / keystore), which runs the ownership exchanges;
|
|
9
|
+
* nothing here persists it.
|
|
10
|
+
*/
|
|
11
|
+
import {sha256Hex} from "./sha256"
|
|
12
|
+
|
|
13
|
+
// Meta's own client identifiers, the same in every copy of Meta's apps. They name the
|
|
14
|
+
// app that asks, not a person. They are not secrets, and no account or session is in them.
|
|
15
|
+
const FRL_CLIENT = "FRL|388177446008673|083800dd7efbbd42eab18c9886d79c18"
|
|
16
|
+
const AR_CLIENT = "AR|306760944872162|a919421a55a8ea18080ab2f10f57f1be"
|
|
17
|
+
const TOKENS_QUERY_URL = "https://meta.graph.meta.com/webview_tokens_query"
|
|
18
|
+
const BLOBS_DECRYPT_URL = "https://meta.graph.meta.com/webview_blobs_decrypt"
|
|
19
|
+
const LOGIN_URL = "https://ar-genai.graph.meta.com/login"
|
|
20
|
+
const AUTH_ENTRY_BASE =
|
|
21
|
+
"https://auth.meta.com/?native_app_id=388177446008673&source_app_id=388177446008673&native_sso_etoken="
|
|
22
|
+
|
|
23
|
+
export class MetaAuthError extends Error {}
|
|
24
|
+
|
|
25
|
+
export interface MetaSsoTokens {
|
|
26
|
+
nativeSsoToken: string
|
|
27
|
+
etoken: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MetaSession {
|
|
31
|
+
accessToken: string
|
|
32
|
+
userId: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function authEntryUrl(tokens: MetaSsoTokens): string {
|
|
36
|
+
return AUTH_ENTRY_BASE + encodeURIComponent(tokens.etoken)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The redirect that ends the sign-in: a custom scheme, or the frl_login marker on https. */
|
|
40
|
+
export function isLoginCallback(url: string): boolean {
|
|
41
|
+
return url.toLowerCase().startsWith("fb-viewapp:") || url.includes("frl_login")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function callbackQuery(url: string): {token?: string; blob?: string} {
|
|
45
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : ""
|
|
46
|
+
const out: {token?: string; blob?: string} = {}
|
|
47
|
+
for (const pair of query.split("&")) {
|
|
48
|
+
const [key, value = ""] = pair.split("=")
|
|
49
|
+
if (key === "token") out.token = decodeURIComponent(value)
|
|
50
|
+
if (key === "blob") out.blob = decodeURIComponent(value)
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The first 16 hex digits of the request token's SHA-256, which the callback must present. */
|
|
56
|
+
export function expectedCallbackToken(nativeSsoToken: string): string {
|
|
57
|
+
return sha256Hex(nativeSsoToken).slice(0, 16)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function callbackMatches(token: string | undefined, nativeSsoToken: string): boolean {
|
|
61
|
+
if (!token || token.length !== 16) return false
|
|
62
|
+
const expected = expectedCallbackToken(nativeSsoToken)
|
|
63
|
+
let difference = 0
|
|
64
|
+
for (let i = 0; i < 16; i++) difference |= token.charCodeAt(i) ^ expected.charCodeAt(i)
|
|
65
|
+
return difference === 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function makeLsd(): string {
|
|
69
|
+
let digits = ""
|
|
70
|
+
for (let i = 0; i < 6; i++) digits += Math.floor(Math.random() * 10)
|
|
71
|
+
return "S0." + digits
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function jazoest(lsd: string): string {
|
|
75
|
+
let sum = 0
|
|
76
|
+
for (const char of lsd) sum += char.codePointAt(0) ?? 0
|
|
77
|
+
return "2" + sum
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function multipartBody(fields: [string, string][], boundary: string): string {
|
|
81
|
+
let body = ""
|
|
82
|
+
for (const [name, value] of fields) {
|
|
83
|
+
body += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`
|
|
84
|
+
}
|
|
85
|
+
return body + `--${boundary}--\r\n`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function urlForm(fields: [string, string][]): string {
|
|
89
|
+
return fields.map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join("&")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Json = Record<string, unknown>
|
|
93
|
+
|
|
94
|
+
async function post(url: string, headers: Record<string, string>, body: string): Promise<Json> {
|
|
95
|
+
const response = await fetch(url, {method: "POST", headers, body})
|
|
96
|
+
let json: Json | null = null
|
|
97
|
+
try {
|
|
98
|
+
json = (await response.json()) as Json
|
|
99
|
+
} catch {
|
|
100
|
+
json = null
|
|
101
|
+
}
|
|
102
|
+
if (response.status !== 200 || json === null) {
|
|
103
|
+
throw new MetaAuthError(`Meta sign-in failed (HTTP ${response.status}). Try again.`)
|
|
104
|
+
}
|
|
105
|
+
if (json.error !== undefined) {
|
|
106
|
+
const error = json.error as {code?: number; error_subcode?: number} | null
|
|
107
|
+
throw new MetaAuthError(
|
|
108
|
+
`Meta sign-in failed (code ${error?.code ?? "-"}, subcode ${error?.error_subcode ?? "-"}). Try again.`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return json
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function postMultipart(url: string, fields: [string, string][]): Promise<Json> {
|
|
115
|
+
const boundary = `glassly.form.${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
|
116
|
+
return post(
|
|
117
|
+
url,
|
|
118
|
+
{
|
|
119
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
120
|
+
"Accept": "application/json",
|
|
121
|
+
"Origin": "https://auth.meta.com",
|
|
122
|
+
},
|
|
123
|
+
multipartBody(fields, boundary),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Step 1: the anonymous WebView tokens query. */
|
|
128
|
+
export async function tokensQuery(): Promise<MetaSsoTokens> {
|
|
129
|
+
const lsd = makeLsd()
|
|
130
|
+
const json = await postMultipart(TOKENS_QUERY_URL, [
|
|
131
|
+
["access_token", FRL_CLIENT],
|
|
132
|
+
["lsd", lsd],
|
|
133
|
+
["jazoest", jazoest(lsd)],
|
|
134
|
+
])
|
|
135
|
+
const nativeSsoToken = json.native_sso_token
|
|
136
|
+
const etoken = json.native_sso_etoken
|
|
137
|
+
if (typeof nativeSsoToken !== "string" || !nativeSsoToken || typeof etoken !== "string" || !etoken) {
|
|
138
|
+
throw new MetaAuthError("Meta didn't return sign-in tokens. Try again.")
|
|
139
|
+
}
|
|
140
|
+
return {nativeSsoToken, etoken}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Step 3: decrypt the login blob into the FRL access token. */
|
|
144
|
+
export async function decryptBlob(blob: string, requestToken: string): Promise<string> {
|
|
145
|
+
const lsd = makeLsd()
|
|
146
|
+
const json = await postMultipart(BLOBS_DECRYPT_URL, [
|
|
147
|
+
["blob", blob],
|
|
148
|
+
["request_token", requestToken],
|
|
149
|
+
["access_token", FRL_CLIENT],
|
|
150
|
+
["lsd", lsd],
|
|
151
|
+
["jazoest", jazoest(lsd)],
|
|
152
|
+
])
|
|
153
|
+
const token = json.access_token
|
|
154
|
+
if (typeof token !== "string" || !token) throw new MetaAuthError("Meta didn't confirm the sign-in. Try again.")
|
|
155
|
+
return token
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function randomId(): string {
|
|
159
|
+
const hex = () =>
|
|
160
|
+
Math.floor(Math.random() * 0x10000)
|
|
161
|
+
.toString(16)
|
|
162
|
+
.padStart(4, "0")
|
|
163
|
+
return `${hex()}${hex()}-${hex()}-${hex()}-${hex()}-${hex()}${hex()}${hex()}`.toUpperCase()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Step 4: exchange the FRL token for the ar user session. */
|
|
167
|
+
export async function login(frlAccessToken: string): Promise<MetaSession> {
|
|
168
|
+
const json = await post(
|
|
169
|
+
LOGIN_URL,
|
|
170
|
+
{
|
|
171
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
172
|
+
"Accept": "application/json",
|
|
173
|
+
"Authorization": "OAuth " + AR_CLIENT,
|
|
174
|
+
},
|
|
175
|
+
urlForm([
|
|
176
|
+
["frl_access_token", frlAccessToken],
|
|
177
|
+
["logging_session_id", randomId()],
|
|
178
|
+
["format", "json"],
|
|
179
|
+
["device_id", randomId()],
|
|
180
|
+
["generate_session_cookies", "1"],
|
|
181
|
+
["generate_analytics_claim", "1"],
|
|
182
|
+
["method", "POST"],
|
|
183
|
+
]),
|
|
184
|
+
)
|
|
185
|
+
const accessToken = json.access_token
|
|
186
|
+
const rawUserId = json.user_id
|
|
187
|
+
const userId = typeof rawUserId === "string" ? rawUserId : typeof rawUserId === "number" ? String(rawUserId) : ""
|
|
188
|
+
if (typeof accessToken !== "string" || !accessToken || !userId) {
|
|
189
|
+
throw new MetaAuthError("Meta didn't return an account session. Try again.")
|
|
190
|
+
}
|
|
191
|
+
return {accessToken, userId}
|
|
192
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Meta Neural Band's own event streams, past the gestures that ride `touch_event`:
|
|
3
|
+
* claim progress (for the pairing screens), the handwriting model (typed into the
|
|
4
|
+
* miniapp keyboard stream as synthesized key presses) and the air mouse (forwarded
|
|
5
|
+
* to miniapps on the pointer stream).
|
|
6
|
+
*/
|
|
7
|
+
import type {NeuralBandClaimEvent, NeuralBandHandwritingEvent, NeuralBandPointerEvent} from "@glassly/bluetooth-sdk"
|
|
8
|
+
import {MiniappStreamType} from "@glassly/miniapp"
|
|
9
|
+
|
|
10
|
+
import type {KeyboardEventPayload} from "../GlassesKeyboardInput"
|
|
11
|
+
import localMiniappRuntime from "../LocalMiniappRuntime"
|
|
12
|
+
|
|
13
|
+
export interface NeuralBandClaimStatus {
|
|
14
|
+
stage: "idle" | "progress" | "completed" | "failed"
|
|
15
|
+
message?: string
|
|
16
|
+
sessionExpired: boolean
|
|
17
|
+
bandName?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NeuralBandHandwritingStatus {
|
|
21
|
+
phase: "idle" | "preparing" | "ready" | "restoring" | "finished"
|
|
22
|
+
message: string
|
|
23
|
+
problem?: string
|
|
24
|
+
/** The last text the band produced since handwriting was switched on. */
|
|
25
|
+
text: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NeuralBandInputOptions {
|
|
29
|
+
forward?: (stream: string, payload: unknown) => void
|
|
30
|
+
now?: () => number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MODIFIERS = {shift: false, ctrl: false, alt: false, meta: false}
|
|
34
|
+
|
|
35
|
+
/** One handwriting change as the key presses that would have typed it. */
|
|
36
|
+
export function keyEventsForTextChange(previous: string, next: string, timestamp: number): KeyboardEventPayload[] {
|
|
37
|
+
let common = 0
|
|
38
|
+
while (common < previous.length && common < next.length && previous[common] === next[common]) common++
|
|
39
|
+
const events: KeyboardEventPayload[] = []
|
|
40
|
+
const press = (key: string, code: string, text?: string) => {
|
|
41
|
+
events.push({phase: "down", key, code, modifiers: MODIFIERS, text, repeat: false, timestamp})
|
|
42
|
+
events.push({phase: "up", key, code, modifiers: MODIFIERS, repeat: false, timestamp})
|
|
43
|
+
}
|
|
44
|
+
for (let i = common; i < previous.length; i++) press("Backspace", "Backspace")
|
|
45
|
+
for (const char of Array.from(next.slice(common))) {
|
|
46
|
+
if (char === "\n") press("Enter", "Enter")
|
|
47
|
+
else if (char === " ") press(" ", "Space", " ")
|
|
48
|
+
else press(char, codeFor(char), char)
|
|
49
|
+
}
|
|
50
|
+
return events
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function codeFor(char: string): string {
|
|
54
|
+
if (/^[a-z]$/i.test(char)) return `Key${char.toUpperCase()}`
|
|
55
|
+
if (/^[0-9]$/.test(char)) return `Digit${char}`
|
|
56
|
+
return "Unidentified"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class NeuralBandInput {
|
|
60
|
+
private claim: NeuralBandClaimStatus = {stage: "idle", sessionExpired: false}
|
|
61
|
+
private handwriting: NeuralBandHandwritingStatus = {phase: "idle", message: "", text: ""}
|
|
62
|
+
private claimListeners = new Set<(status: NeuralBandClaimStatus) => void>()
|
|
63
|
+
private handwritingListeners = new Set<(status: NeuralBandHandwritingStatus) => void>()
|
|
64
|
+
private readonly forward: (stream: string, payload: unknown) => void
|
|
65
|
+
private readonly now: () => number
|
|
66
|
+
|
|
67
|
+
constructor(options: NeuralBandInputOptions = {}) {
|
|
68
|
+
this.forward = options.forward ?? ((stream, payload) => localMiniappRuntime.forwardEvent(stream, payload))
|
|
69
|
+
this.now = options.now ?? (() => Date.now())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
claimStatus(): NeuralBandClaimStatus {
|
|
73
|
+
return this.claim
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onClaimStatus(cb: (status: NeuralBandClaimStatus) => void): () => void {
|
|
77
|
+
this.claimListeners.add(cb)
|
|
78
|
+
return () => this.claimListeners.delete(cb)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
handwritingStatus(): NeuralBandHandwritingStatus {
|
|
82
|
+
return this.handwriting
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
onHandwritingStatus(cb: (status: NeuralBandHandwritingStatus) => void): () => void {
|
|
86
|
+
this.handwritingListeners.add(cb)
|
|
87
|
+
return () => this.handwritingListeners.delete(cb)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A new pairing attempt starts from a clean slate. */
|
|
91
|
+
resetClaim(): void {
|
|
92
|
+
this.setClaim({stage: "idle", sessionExpired: false})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
handleClaim(event: NeuralBandClaimEvent): void {
|
|
96
|
+
this.setClaim({
|
|
97
|
+
stage: event.stage,
|
|
98
|
+
message: event.message,
|
|
99
|
+
sessionExpired: event.sessionExpired === true,
|
|
100
|
+
bandName: event.bandName,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
handleHandwriting(event: NeuralBandHandwritingEvent): void {
|
|
105
|
+
if (event.kind === "state") {
|
|
106
|
+
const text = event.phase === "preparing" ? "" : this.handwriting.text
|
|
107
|
+
this.setHandwriting({phase: event.phase, message: event.message, problem: event.problem, text})
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const previous = this.handwriting.text
|
|
111
|
+
if (event.text !== previous) {
|
|
112
|
+
for (const key of keyEventsForTextChange(previous, event.text, this.now())) {
|
|
113
|
+
this.forward(MiniappStreamType.KEYBOARD_EVENT, key)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this.setHandwriting({...this.handwriting, text: event.text})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
handlePointer(event: NeuralBandPointerEvent): void {
|
|
120
|
+
if (event.kind === "move") {
|
|
121
|
+
this.forward(MiniappStreamType.POINTER_EVENT, {
|
|
122
|
+
kind: "move",
|
|
123
|
+
dx: event.dx,
|
|
124
|
+
dy: event.dy,
|
|
125
|
+
timestamp: event.timestamp,
|
|
126
|
+
})
|
|
127
|
+
} else {
|
|
128
|
+
this.forward(MiniappStreamType.POINTER_EVENT, {
|
|
129
|
+
kind: "buttons",
|
|
130
|
+
buttons: event.buttons,
|
|
131
|
+
timestamp: event.timestamp,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private setClaim(status: NeuralBandClaimStatus): void {
|
|
137
|
+
this.claim = status
|
|
138
|
+
for (const cb of this.claimListeners) cb(status)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private setHandwriting(status: NeuralBandHandwritingStatus): void {
|
|
142
|
+
this.handwriting = status
|
|
143
|
+
for (const cb of this.handwritingListeners) cb(status)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const neuralBandInput = new NeuralBandInput()
|
|
148
|
+
export default neuralBandInput
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pushes the Neural Band's user settings (air mouse, wearing hand) to the native
|
|
3
|
+
* controller: when the band connects, and whenever either setting changes while
|
|
4
|
+
* it is connected. The native side keeps nothing across connections.
|
|
5
|
+
*/
|
|
6
|
+
import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
|
|
7
|
+
|
|
8
|
+
import {useGlassesStore} from "../../stores/glasses"
|
|
9
|
+
import {SETTINGS, useSettingsStore} from "../../stores/settings"
|
|
10
|
+
import {ControllerTypes} from "../../types/enums"
|
|
11
|
+
|
|
12
|
+
let unsubscribers: (() => void)[] = []
|
|
13
|
+
|
|
14
|
+
function bandConnected(): boolean {
|
|
15
|
+
const glasses = useGlassesStore.getState()
|
|
16
|
+
const settings = useSettingsStore.getState()
|
|
17
|
+
return (
|
|
18
|
+
glasses.controllerConnected === true &&
|
|
19
|
+
glasses.controllerFullyBooted === true &&
|
|
20
|
+
settings.getSetting(SETTINGS.default_controller.key) === ControllerTypes.NEURAL_BAND
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pushAirMouse(): void {
|
|
25
|
+
if (!bandConnected()) return
|
|
26
|
+
const enabled = useSettingsStore.getState().getSetting(SETTINGS.neural_band_air_mouse.key) === true
|
|
27
|
+
void BluetoothSdk.neuralBandSetPointer(enabled).catch((err) =>
|
|
28
|
+
console.warn("NeuralBandSettingsSync: air mouse push failed:", err),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pushHand(): void {
|
|
33
|
+
if (!bandConnected()) return
|
|
34
|
+
const hand = useSettingsStore.getState().getSetting(SETTINGS.neural_band_hand.key) === "left" ? "left" : "right"
|
|
35
|
+
void BluetoothSdk.neuralBandSetHand(hand).catch((err) =>
|
|
36
|
+
console.warn("NeuralBandSettingsSync: hand push failed:", err),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function startNeuralBandSettingsSync(): void {
|
|
41
|
+
if (unsubscribers.length > 0) return
|
|
42
|
+
let wasConnected = bandConnected()
|
|
43
|
+
unsubscribers = [
|
|
44
|
+
useGlassesStore.subscribe(
|
|
45
|
+
(s) => s.controllerConnected === true && s.controllerFullyBooted === true,
|
|
46
|
+
() => {
|
|
47
|
+
const connected = bandConnected()
|
|
48
|
+
if (connected && !wasConnected) {
|
|
49
|
+
pushAirMouse()
|
|
50
|
+
pushHand()
|
|
51
|
+
}
|
|
52
|
+
wasConnected = connected
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.neural_band_air_mouse.key), pushAirMouse),
|
|
56
|
+
useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.neural_band_hand.key), pushHand),
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function stopNeuralBandSettingsSync(): void {
|
|
61
|
+
for (const unsubscribe of unsubscribers) unsubscribe()
|
|
62
|
+
unsubscribers = []
|
|
63
|
+
}
|