@glassly/engine 0.3.8-dev.1015 → 0.3.8-dev.1016

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.
@@ -673,6 +673,9 @@ class LocalMiniappRuntime {
673
673
  private connectedApps: Map<string, ConnectedMiniapp> = new Map()
674
674
  /** Claimed gestures (CLAIM_GESTURES): a stack per gesture, the most recent claimer holding it. */
675
675
  private gestureClaims: GestureClaimState = emptyGestureClaims()
676
+ /** Apps with a text field open on the glasses (TEXT_FIELD). */
677
+ private textFieldApps = new Set<string>()
678
+ private textFieldListeners = new Set<(open: boolean) => void>()
676
679
  // The last main-view frame each app asked to render. Widget mode refuses
677
680
  // frames and demotion clears them, and an app that has drawn once may not
678
681
  // draw again on its own, so promotion replays this.
@@ -1295,6 +1298,7 @@ class LocalMiniappRuntime {
1295
1298
  }
1296
1299
  // Its gesture claims go back to whoever claimed them before.
1297
1300
  this.applyGestureClaimChanges(dropGestureClaims(this.gestureClaims, packageName))
1301
+ this.setTextFieldOpen(packageName, false)
1298
1302
  this.retainedRenders.delete(packageName)
1299
1303
 
1300
1304
  // Warm-up is a request-owned camera lease. Cancel it even if the app record was already
@@ -1478,6 +1482,9 @@ class LocalMiniappRuntime {
1478
1482
  case MiniappRequestType.CLAIM_GESTURES:
1479
1483
  this.handleClaimGestures(packageName, payload, requestId)
1480
1484
  break
1485
+ case MiniappRequestType.TEXT_FIELD:
1486
+ this.handleTextField(packageName, payload, requestId)
1487
+ break
1481
1488
  case MiniappRequestType.DISPLAY:
1482
1489
  this.handleDisplay(packageName, payload, requestId)
1483
1490
  break
@@ -2214,6 +2221,48 @@ class LocalMiniappRuntime {
2214
2221
  }
2215
2222
  }
2216
2223
 
2224
+ /**
2225
+ * TEXT_FIELD (session.input.setTextFieldOpen): the app has a text field on
2226
+ * the glasses, or no longer does. Text entry that costs power to leave on
2227
+ * (Neural Band handwriting) runs while any app has one. Cleared on app stop.
2228
+ */
2229
+ private handleTextField(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
2230
+ if (typeof payload.open !== "boolean") {
2231
+ this.sendResult(packageName, requestId, false, undefined, {
2232
+ code: MiniappErrorCode.INVALID_ARGUMENT,
2233
+ message: "Text field open must be a boolean",
2234
+ })
2235
+ return
2236
+ }
2237
+ if (this.connectedApps.has(packageName)) this.setTextFieldOpen(packageName, payload.open)
2238
+ this.sendResult(packageName, requestId, true)
2239
+ }
2240
+
2241
+ private setTextFieldOpen(packageName: string, open: boolean): void {
2242
+ const before = this.textFieldApps.size > 0
2243
+ if (open) this.textFieldApps.add(packageName)
2244
+ else this.textFieldApps.delete(packageName)
2245
+ const after = this.textFieldApps.size > 0
2246
+ if (before === after) return
2247
+ for (const cb of this.textFieldListeners) {
2248
+ try {
2249
+ cb(after)
2250
+ } catch (err) {
2251
+ console.warn(`${LOG_TAG}: text field listener failed:`, err)
2252
+ }
2253
+ }
2254
+ }
2255
+
2256
+ /** True while any running miniapp has a text field open on the glasses. */
2257
+ public hasOpenTextField(): boolean {
2258
+ return this.textFieldApps.size > 0
2259
+ }
2260
+
2261
+ public onTextFieldChange(cb: (open: boolean) => void): () => void {
2262
+ this.textFieldListeners.add(cb)
2263
+ return () => this.textFieldListeners.delete(cb)
2264
+ }
2265
+
2217
2266
  /** The app holding `gesture` (the most recent claimer), or null when it is unclaimed. */
2218
2267
  public gestureClaimant(gesture: string): string | null {
2219
2268
  const claimant = gestureClaimant(this.gestureClaims, gesture)
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Decides when each connected Meta Neural Band runs its handwriting model. The
3
+ * model streams to the phone for as long as it runs, written on or not, so it
4
+ * runs only while it can type somewhere: the wearer has handwriting enabled
5
+ * and a miniapp has a text field open on the glasses. It outlives the last
6
+ * field by OFF_DELAY_MS, so moving between fields doesn't restart it. A session
7
+ * the band ends on its own (timeout, refused setting, silence) isn't retried
8
+ * until no field is open, so a band that can't run the model isn't asked again
9
+ * and again.
10
+ */
11
+ import type {NeuralBandHandwritingStatus} from "./NeuralBandInput"
12
+
13
+ type Phase = NeuralBandHandwritingStatus["phase"]
14
+
15
+ export interface HandwritingGateDeps {
16
+ enabled: () => boolean
17
+ textFieldOpen: () => boolean
18
+ setHandwriting: (id: string, enabled: boolean) => Promise<void>
19
+ setTimeout: (fn: () => void, ms: number) => unknown
20
+ clearTimeout: (handle: unknown) => void
21
+ }
22
+
23
+ interface Band {
24
+ /** What we last asked this band for. */
25
+ requested: boolean
26
+ failed: boolean
27
+ phase: Phase
28
+ }
29
+
30
+ export const OFF_DELAY_MS = 5_000
31
+ const ACTIVE = new Set<Phase>(["preparing", "ready", "restoring"])
32
+
33
+ export class HandwritingGate {
34
+ private bands = new Map<string, Band>()
35
+ private offTimer: unknown = null
36
+
37
+ constructor(private readonly deps: HandwritingGateDeps) {}
38
+
39
+ /** The connected bands. A band keeps nothing across connections, so a reconnect starts fresh. */
40
+ setBands(ids: string[]): void {
41
+ for (const id of [...this.bands.keys()]) if (!ids.includes(id)) this.bands.delete(id)
42
+ for (const id of ids) if (!this.bands.has(id)) this.bands.set(id, {requested: false, failed: false, phase: "idle"})
43
+ this.update()
44
+ }
45
+
46
+ /** A band reported its handwriting phase. */
47
+ status(id: string, phase: Phase): void {
48
+ const band = this.bands.get(id)
49
+ if (!band) return
50
+ band.phase = phase
51
+ if (phase === "finished" && band.requested) {
52
+ band.requested = false
53
+ band.failed = true
54
+ }
55
+ this.update()
56
+ }
57
+
58
+ /** Re-evaluate after the setting or the open text fields changed. */
59
+ update(): void {
60
+ const enabled = this.deps.enabled()
61
+ if (enabled && this.deps.textFieldOpen()) {
62
+ this.clearOffTimer()
63
+ for (const [id, band] of this.bands) {
64
+ // A session still winding down (or one we didn't start) has to finish first.
65
+ if (band.requested || band.failed || ACTIVE.has(band.phase)) continue
66
+ this.request(id, band, true)
67
+ }
68
+ return
69
+ }
70
+ for (const band of this.bands.values()) band.failed = false
71
+ if (![...this.bands.values()].some((band) => band.requested)) {
72
+ this.clearOffTimer()
73
+ return
74
+ }
75
+ if (!enabled) {
76
+ this.clearOffTimer()
77
+ this.stopAll()
78
+ } else if (this.offTimer === null) {
79
+ this.offTimer = this.deps.setTimeout(() => {
80
+ this.offTimer = null
81
+ if (!this.deps.textFieldOpen() || !this.deps.enabled()) this.stopAll()
82
+ }, OFF_DELAY_MS)
83
+ }
84
+ }
85
+
86
+ dispose(): void {
87
+ this.clearOffTimer()
88
+ this.bands.clear()
89
+ }
90
+
91
+ private stopAll(): void {
92
+ for (const [id, band] of this.bands) if (band.requested) this.request(id, band, false)
93
+ }
94
+
95
+ private request(id: string, band: Band, enabled: boolean): void {
96
+ band.requested = enabled
97
+ this.deps.setHandwriting(id, enabled).catch((err) => {
98
+ console.warn(`HandwritingGate: turning handwriting ${enabled ? "on" : "off"} failed:`, err)
99
+ if (enabled && this.bands.get(id) === band && band.requested) {
100
+ band.requested = false
101
+ band.failed = true
102
+ }
103
+ })
104
+ }
105
+
106
+ private clearOffTimer(): void {
107
+ if (this.offTimer === null) return
108
+ this.deps.clearTimeout(this.offTimer)
109
+ this.offTimer = null
110
+ }
111
+ }
@@ -1,16 +1,23 @@
1
1
  /**
2
- * Pushes the Neural Band user settings (air mouse, wearing hand) to every
3
- * connected band: to each band as it connects, and to all of them whenever
4
- * either setting changes. The native side keeps nothing across connections.
2
+ * Pushes the Neural Band user settings (air mouse, wearing hand, vibration) to
3
+ * every connected band: to each band as it connects, and to all of them
4
+ * whenever a setting changes. The native side keeps nothing across
5
+ * connections. Handwriting is gated, not pushed: HandwritingGate runs it while
6
+ * the setting is on and a miniapp has a text field open.
5
7
  */
6
8
  import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
7
9
 
8
10
  import {useGlassesStore} from "../../stores/glasses"
9
11
  import {SETTINGS, useSettingsStore} from "../../stores/settings"
10
12
  import {ControllerTypes} from "../../types/enums"
13
+ import {BgTimer} from "../../utils/timers"
11
14
  import {readyControllers} from "../controllers"
15
+ import localMiniappRuntime from "../LocalMiniappRuntime"
16
+ import {HandwritingGate} from "./HandwritingGate"
17
+ import neuralBandInput from "./NeuralBandInput"
12
18
 
13
19
  let unsubscribers: (() => void)[] = []
20
+ let handwriting: HandwritingGate | null = null
14
21
 
15
22
  function connectedBandIds(): string[] {
16
23
  return readyControllers(useGlassesStore.getState().controllers)
@@ -39,10 +46,32 @@ function pushVibration(id: string): void {
39
46
  )
40
47
  }
41
48
 
49
+ function startHandwritingGate(): (() => void)[] {
50
+ const gate = new HandwritingGate({
51
+ enabled: () => useSettingsStore.getState().getSetting(SETTINGS.neural_band_handwriting.key) !== false,
52
+ textFieldOpen: () => localMiniappRuntime.hasOpenTextField(),
53
+ setHandwriting: (id, enabled) => BluetoothSdk.neuralBandSetHandwriting(id, enabled),
54
+ setTimeout: (fn, ms) => BgTimer.setTimeout(fn, ms),
55
+ clearTimeout: (handle) => BgTimer.clearTimeout(handle as number),
56
+ })
57
+ handwriting = gate
58
+ gate.setBands(connectedBandIds())
59
+ return [
60
+ useSettingsStore.subscribe(
61
+ (s) => s.getSetting(SETTINGS.neural_band_handwriting.key),
62
+ () => gate.update(),
63
+ ),
64
+ localMiniappRuntime.onTextFieldChange(() => gate.update()),
65
+ neuralBandInput.onHandwritingStatus(({id, status}) => gate.status(id, status.phase)),
66
+ () => gate.dispose(),
67
+ ]
68
+ }
69
+
42
70
  export function startNeuralBandSettingsSync(): void {
43
71
  if (unsubscribers.length > 0) return
44
72
  let connected = new Set(connectedBandIds())
45
73
  unsubscribers = [
74
+ ...startHandwritingGate(),
46
75
  useGlassesStore.subscribe(
47
76
  () => connectedBandIds().sort().join(","),
48
77
  () => {
@@ -54,6 +83,7 @@ export function startNeuralBandSettingsSync(): void {
54
83
  pushVibration(id)
55
84
  }
56
85
  connected = next
86
+ handwriting?.setBands([...next])
57
87
  },
58
88
  ),
59
89
  useSettingsStore.subscribe(
@@ -74,4 +104,5 @@ export function startNeuralBandSettingsSync(): void {
74
104
  export function stopNeuralBandSettingsSync(): void {
75
105
  for (const unsubscribe of unsubscribers) unsubscribe()
76
106
  unsubscribers = []
107
+ handwriting = null
77
108
  }
@@ -848,6 +848,16 @@ export const SETTINGS: Record<string, Setting> = {
848
848
  saveOnServer: false,
849
849
  persist: true,
850
850
  },
851
+ // Meta Neural Band handwriting typing into miniapps. Enabled means the band runs
852
+ // its handwriting model while a miniapp has a text field open on the glasses
853
+ // (HandwritingGate); the model streams the whole time it runs, so never otherwise.
854
+ neural_band_handwriting: {
855
+ key: "neural_band_handwriting",
856
+ defaultValue: () => true,
857
+ writable: true,
858
+ saveOnServer: false,
859
+ persist: true,
860
+ },
851
861
  // The band's own buzz on each gesture (its haptics pattern 1; pattern 2 is silent).
852
862
  neural_band_vibration: {
853
863
  key: "neural_band_vibration",