From 70881c2034985babfb13d2d9f23700a1d5f773be Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Tue, 4 Aug 2026 14:47:57 +0800 Subject: [PATCH 1/3] fix(connect): bound the connect flow, explain failures, steady the layout A keyboard that matches the vendor usage filter without speaking rynk (a Vial board shares usage page 0xFF60) could park the connect flow forever: WebHID sendReport never settles, the probe deadline never covered it, and the whole screen stayed disabled. - probeVersion runs under an idle watchdog that also covers the send: every answer rearms the 5s window, so a slow link that is still talking is never cut off, and only silence gives up. Opening the transport gets its own 15s deadline, a late open closes itself, and WebHidLink.close no longer wedges behind a stuck sendReport. - Failures now explain themselves: explainKeyboardError maps each cause to a title plus a next step (port busy, not RMK firmware, link lost), shown as error toasts that stay until dismissed and dedupe on retry. A row whose last attempt failed keeps a red 'failed' pill instead of falling back to 'ready'. - The connect screen centres as one column and scrolls when the viewport is too short (phones). Both tabs' lists stack in one grid cell so switching tabs cannot move the card; the empty-state floor is exactly one device row, so native builds with a single keyboard show no dead space; native builds get native hints instead of browser copy. Signed-off-by: Haobo Gu --- src/components/ui/Pill.svelte | 3 +- src/components/ui/Segmented.svelte | 7 +- src/lib/Toaster.svelte | 28 +++- src/lib/toast.svelte.ts | 24 ++- src/pages/Connect.svelte | 199 ++++++++++++++----------- src/rynk/core.test.ts | 32 ++++ src/rynk/core.ts | 33 +++- src/rynk/web.ts | 14 +- src/stores/devices.svelte.ts | 78 ++++++++-- src/stores/keyboard/errors.test.ts | 46 +++++- src/stores/keyboard/errors.ts | 47 ++++++ src/stores/keyboard/index.ts | 4 +- src/stores/keyboard/keyboard.svelte.ts | 8 +- 13 files changed, 393 insertions(+), 130 deletions(-) diff --git a/src/components/ui/Pill.svelte b/src/components/ui/Pill.svelte index 5d4e86c..3b30c5e 100644 --- a/src/components/ui/Pill.svelte +++ b/src/components/ui/Pill.svelte @@ -2,7 +2,7 @@ import type { Snippet } from 'svelte' interface Props { - tone?: 'ok' | 'blue' | 'muted' + tone?: 'ok' | 'blue' | 'muted' | 'bad' /// Renders the small filled dot the status pills lead with. dot?: boolean children: Snippet @@ -14,6 +14,7 @@ ok: 'border-ok-bright/30 bg-ok-bright/10 text-ok', blue: 'border-info/30 bg-info/10 text-info', muted: 'border-border bg-muted text-muted-foreground', + bad: 'border-destructive/30 bg-destructive/10 text-destructive', } diff --git a/src/components/ui/Segmented.svelte b/src/components/ui/Segmented.svelte index 0e85968..8f200ac 100644 --- a/src/components/ui/Segmented.svelte +++ b/src/components/ui/Segmented.svelte @@ -45,7 +45,12 @@ disabled:cursor-not-allowed `, fill && 'flex-1', - on ? 'bg-card text-brand-darker shadow-xs dark:text-brand-fill' : 'text-muted-foreground', + on + ? ` + bg-card text-brand-darker shadow-xs + dark:text-brand-fill + ` + : `text-muted-foreground`, ]} style='height: {height}px' value={item.value} diff --git a/src/lib/Toaster.svelte b/src/lib/Toaster.svelte index 2f93993..b9141a0 100644 --- a/src/lib/Toaster.svelte +++ b/src/lib/Toaster.svelte @@ -22,18 +22,36 @@ aria-live='polite' > {#each toast.items as t (t.id)} +
toast.hold(t.id)} onmouseleave={() => toast.release(t.id)} > - - {t.title} + + + {t.title} + {#if t.detail} + + {t.detail} + + {/if} + + {/each} + + {#if deviceStore.scanning && list.length === 0} +

+ + Looking for {m.label} keyboards… +

+ {:else if list.length === 0} +

+ No {m.label} keyboards yet. {HINTS[m.value]} +

{/if} - +
{/each} - - {#if deviceStore.scanning && found.length === 0} -

- - Looking for {label(method)} keyboards… -

- {:else if found.length === 0} -

- No {label(method)} keyboards yet. {HINTS[method]} -

- {/if} {#if canPick} @@ -195,16 +223,17 @@ : 'Choose a keyboard…'} {:else if !native} -

+ +

This browser has no {label(method)} support.

{/if} - - {#if deviceStore.error} -

- {deviceStore.error} -

- {/if} diff --git a/src/rynk/core.test.ts b/src/rynk/core.test.ts index e11add2..83d4f27 100644 --- a/src/rynk/core.test.ts +++ b/src/rynk/core.test.ts @@ -88,6 +88,38 @@ describe('probeVersion', () => { await expect(probeVersion(silent, 10)).rejects.toThrow('version probe timed out') }) + it('keeps waiting as long as the device keeps talking', async () => { + // The window is idle time, not a cap on the whole probe: ten topic pushes + // 20ms apart blow far past a 100ms total budget, and only then does the + // reply land. An absolute deadline would cut this device off mid-sentence. + const topic = cobsEncode(new Uint8Array([0x10, 0x80, 0, 0, 9, 9])) + let calls = 0 + const chatty: JsByteLink = { + label: 'chatty', + async send() {}, + recv: () => new Promise((resolve) => { + calls += 1 + const frame = calls <= 10 ? topic : versionReply(0x0001, 1, 2) + setTimeout(resolve, 20, frame) + }), + async close() {}, + } + await expect(probeVersion(chatty, 100)).resolves.toEqual({ major: 1, minor: 2 }) + }) + + it('gives up when the send itself never settles', async () => { + // WebHID sendReport can park forever on a device that matches the vendor + // usage without being an RMK keyboard (a Vial board). The deadline has to + // cover the send, or the connect screen wedges before the recv loop starts. + const stuck: JsByteLink = { + label: 'stuck', + send: () => new Promise(() => {}), + recv: () => new Promise(() => {}), + async close() {}, + } + await expect(probeVersion(stuck, 10)).rejects.toThrow('version probe timed out') + }) + it('classifies both give-up paths as transport faults', async () => { // toKeyboardError() keys off the name; anything else lands in `unknown`. await expect(probeVersion(fakeLink([]))).rejects.toMatchObject({ name: 'TransportError' }) diff --git a/src/rynk/core.ts b/src/rynk/core.ts index e6ebbc6..7c7142c 100644 --- a/src/rynk/core.ts +++ b/src/rynk/core.ts @@ -7,9 +7,11 @@ export interface JsByteLink { const GET_VERSION = 0x0001 -/// A device can open the port and then never answer. Without a deadline the -/// probe parks forever and leaves the store stuck in `connecting`. -const PROBE_TIMEOUT_MS = 10_000 +/// A device can open the port and then never answer. Without a watchdog the +/// probe parks forever and leaves the store stuck in `connecting`. This is +/// idle time, not a cap on the whole probe: every answer rearms the clock, so +/// a slow link that is still talking gets to keep going — only silence gives up. +const PROBE_TIMEOUT_MS = 5_000 /// Each zero-free run is prefixed by its length + 1; 0x00 delimits frames. export function cobsEncode(data: Uint8Array): Uint8Array { @@ -68,21 +70,36 @@ function transportError(message: string): Error { /// Frame: cmd=0x0001 LE, seq=1, empty payload; reply payload is [status, major, minor]. export async function probeVersion(link: JsByteLink, timeoutMs = PROBE_TIMEOUT_MS) { - await link.send(cobsEncode(new Uint8Array([GET_VERSION & 0xFF, GET_VERSION >> 8, 1]))) let timer: ReturnType | undefined + let expire: () => void = () => {} const deadline = new Promise((_res, rej) => { - timer = setTimeout(() => rej(transportError('version probe timed out')), timeoutMs) + expire = () => rej(transportError('version probe timed out')) }) - // The race usually resolves first; keep the loser from surfacing as an - // unhandled rejection. + /// The watchdog: each call pushes the deadline out by a full window. + const arm = () => { + clearTimeout(timer) + timer = setTimeout(expire, timeoutMs) + } + // The race usually resolves first; keep the losers from surfacing as + // unhandled rejections. deadline.catch(() => {}) try { + arm() + // The watchdog covers the send too: WebHID's sendReport can itself park + // forever on a device that matches the vendor usage but is not an RMK + // keyboard (a Vial board shares usage page 0xFF60). + const sent = link.send(cobsEncode(new Uint8Array([GET_VERSION & 0xFF, GET_VERSION >> 8, 1]))) + sent.catch(() => {}) + await Promise.race([sent, deadline]) let rx: Uint8Array = new Uint8Array(0) for (;;) { const delim = rx.indexOf(0) if (delim === -1) { - const chunk = await Promise.race([link.recv(), deadline]) + const received = link.recv() + received.catch(() => {}) + const chunk = await Promise.race([received, deadline]) if (!chunk.length) throw transportError('link closed') + arm() rx = concat(rx, chunk) continue } diff --git a/src/rynk/web.ts b/src/rynk/web.ts index ec358ff..69d7078 100644 --- a/src/rynk/web.ts +++ b/src/rynk/web.ts @@ -117,12 +117,14 @@ export class WebHidLink extends BufferedLink { async close(): Promise { this.device.removeEventListener('inputreport', this.listener) this.end() - try { - await this.device.close() - } - catch { - // Already gone; the session is over either way. - } + // close() queues behind any sendReport the device never accepted, so it can + // park as long as the send does. The session is already over once the + // listener is gone — give the OS handle a moment, then move on rather than + // wedge the teardown that the connect screen is waiting on. + await Promise.race([ + this.device.close().catch(() => {}), + new Promise(resolve => setTimeout(resolve, 1_000)), + ]) } } diff --git a/src/stores/devices.svelte.ts b/src/stores/devices.svelte.ts index c2ef11d..db21157 100644 --- a/src/stores/devices.svelte.ts +++ b/src/stores/devices.svelte.ts @@ -1,8 +1,10 @@ -import type { TransportInfo } from '../rynk' +import type { ConnectedDevice, TransportInfo } from '../rynk' +import type { KeyboardError } from './keyboard' import { isTauri } from '@tauri-apps/api/core' import { rememberDeviceName } from '../lib/device-names' +import { toast } from '../lib/toast.svelte' import { canUseWebHid, canUseWebSerial, closeAllSessions, discover, requestHidDevice, requestSerialPort } from '../rynk' -import { describeKeyboardError, keyboardStore } from './keyboard' +import { explainKeyboardError, keyboardStore, toKeyboardError } from './keyboard' function describe(e: unknown): string { return e instanceof Error ? e.message : String(e) @@ -13,20 +15,55 @@ function describe(e: unknown): string { /// connect clears it. const STAY_DISCONNECTED_KEY = 'rmk-stay-disconnected' +/// Opening can park forever, not just fail: WebHID open/sendReport never +/// settles on some devices that match the usage filter without being RMK +/// keyboards. Generous enough for a slow BLE link, but bounded, so the store +/// always gets its `connecting` state back. Named TransportError so the error +/// mapping reads it as the link's fault, not the app's. +const OPEN_TIMEOUT_MS = 15_000 + +async function connectWithDeadline(info: TransportInfo): Promise { + let timer: ReturnType | undefined + const opening = info.connect() + try { + return await Promise.race([ + opening, + new Promise((_res, rej) => { + timer = setTimeout(() => { + const e = new Error('connect timed out') + e.name = 'TransportError' + rej(e) + }, OPEN_TIMEOUT_MS) + }), + ]) + } + catch (e) { + // An open that succeeds after the deadline would hold the port and block + // every later attempt; close it whenever it finally lands. + opening.then(d => void d.link.close().catch(() => {})).catch(() => {}) + throw e + } + finally { + clearTimeout(timer) + } +} + class DeviceStoreClass { #devices = $state([]) #scanning = $state(false) /// Transport id being connected, so only that row shows a spinner. #connecting = $state(null) + /// Rows whose last connect attempt failed: falling back to 'ready' would be + /// a lie about a keyboard that just proved otherwise. A new attempt on the + /// same row clears its mark. + #failed = $state([]) #connectedId = $state(null) #connectedKind = $state(null) - #error = $state(null) #booted = false get devices() { return this.#devices } get scanning() { return this.#scanning } get connecting() { return this.#connecting } - get error() { return this.#error } /// Only meaningful while the session is live: the keyboard store owns the /// connection, and it can drop the link without telling us which id died. @@ -46,7 +83,7 @@ class DeviceStoreClass { this.#devices = await discover() } catch (e) { - this.#error = describe(e) + toast.error(describe(e)) } finally { this.#scanning = false @@ -82,13 +119,11 @@ class DeviceStoreClass { /// Opens a listed device into the keyboard store. Assumes the caller owns /// `#connecting` and has already dropped any previous session. private async open(info: TransportInfo): Promise { - this.#error = null + this.#failed = this.#failed.filter(id => id !== info.id) try { - const result = await keyboardStore.initStore(await info.connect()) + const result = await keyboardStore.initStore(await connectWithDeadline(info)) if (result.isErr()) { - this.#error = describeKeyboardError(result.error) - this.#connectedId = null - this.#connectedKind = null + this.fail(result.error, info.id) return } this.#connectedId = info.id @@ -101,12 +136,23 @@ class DeviceStoreClass { } } catch (e) { - this.#error = describe(e) - this.#connectedId = null - this.#connectedKind = null + this.fail(toKeyboardError(e), info.id) } } + /// `id` is the row to mark failed; picker-level faults have no row. + private fail(error: KeyboardError, id?: string) { + const help = explainKeyboardError(error) + toast.error(help.title, help.hint) + if (id !== undefined) this.#failed.push(id) + this.#connectedId = null + this.#connectedKind = null + } + + hasFailed(id: string): boolean { + return this.#failed.includes(id) + } + /// Browser path: the picker the browser opens *is* the device list, and it /// needs the click that called this to still be the active user gesture. /// `hid` reaches a Bluetooth keyboard the OS already bonded; Web Bluetooth @@ -115,7 +161,6 @@ class DeviceStoreClass { if (this.#connecting) return localStorage.removeItem(STAY_DISCONNECTED_KEY) this.#connecting = `web-${kind}` - this.#error = null try { // The picker only grants access; the grant then joins the same list every // other device comes from, so one device never has two identities. @@ -130,7 +175,9 @@ class DeviceStoreClass { } catch (e) { // NotFoundError is the user dismissing the picker, not a failure. - if (!(e instanceof DOMException && e.name === 'NotFoundError')) this.#error = describe(e) + if (!(e instanceof DOMException && e.name === 'NotFoundError')) { + this.fail(toKeyboardError(e)) + } } finally { this.#connecting = null @@ -149,7 +196,6 @@ class DeviceStoreClass { localStorage.setItem(STAY_DISCONNECTED_KEY, '1') this.#connectedId = null this.#connectedKind = null - this.#error = null await keyboardStore.disconnect() } } diff --git a/src/stores/keyboard/errors.test.ts b/src/stores/keyboard/errors.test.ts index 3e6f904..3cf9a68 100644 --- a/src/stores/keyboard/errors.test.ts +++ b/src/stores/keyboard/errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { describeKeyboardError, RYNK_ERROR_CODES, toKeyboardError } from './errors' +import { describeKeyboardError, explainKeyboardError, RYNK_ERROR_CODES, toKeyboardError } from './errors' function named(name: string, message: string): Error { const e = new Error(message) @@ -48,3 +48,47 @@ describe('describeKeyboardError', () => { expect(describeKeyboardError({ type: 'unknown', cause: 42 })).toBe('unknown error') }) }) + +describe('explainKeyboardError', () => { + it('reads a probe timeout as a keyboard that never answered', () => { + // Both deadlines say 'timed out': the version probe and the open guard. + for (const message of ['version probe timed out', 'connect timed out']) { + const help = explainKeyboardError(toKeyboardError(named('TransportError', message))) + expect(help.title).toBe('The keyboard didn’t respond') + expect(help.hint).toContain('RMK firmware') + } + }) + + it('reads a mid-session death as a lost connection', () => { + const help = explainKeyboardError({ type: 'transport', cause: new Error('link closed') }) + expect(help.title).toBe('Connection lost') + }) + + it('reads the open failures every stack reports as a busy device', () => { + // Web Serial and WebHID fail an occupied open with a DOMException name; + // the native serial and BLE stacks say busy/denied in prose. + const causes = [ + named('NetworkError', 'Failed to open serial port.'), + named('InvalidStateError', 'The port is already open.'), + named('NotAllowedError', 'Failed to open the device.'), + new Error('Resource busy'), + new Error('Access denied'), + ] + for (const cause of causes) { + const help = explainKeyboardError(toKeyboardError(cause)) + expect(help.title).toBe('Couldn’t open the device') + expect(help.hint).toContain('Another app') + } + }) + + it('falls back to the raw message as the hint', () => { + const help = explainKeyboardError({ type: 'unknown', cause: new Error('boom') }) + expect(help).toEqual({ title: 'Connection failed', hint: 'boom' }) + expect(explainKeyboardError({ type: 'unknown', cause: 42 })).toEqual({ title: 'Connection failed' }) + }) + + it('names the rejection code in the hint', () => { + const help = explainKeyboardError({ type: 'rynk', code: 'Locked' }) + expect(help.hint).toContain('Locked') + }) +}) diff --git a/src/stores/keyboard/errors.ts b/src/stores/keyboard/errors.ts index d985d7d..1ff3e0b 100644 --- a/src/stores/keyboard/errors.ts +++ b/src/stores/keyboard/errors.ts @@ -54,3 +54,50 @@ export function describeKeyboardError(e: KeyboardError): string { .with({ type: 'unknown' }, x => (x.cause instanceof Error ? x.cause.message : 'unknown error')) .exhaustive() } + +/// A headline plus what to try next — for the connect screen, where the user +/// has room to read and a next step to take. +export interface KeyboardErrorHelp { + title: string + hint?: string +} + +/// The shapes an occupied or unreachable device fails an open with: Web Serial +/// throws NetworkError/InvalidStateError, WebHID NotAllowedError, and the +/// native serial and BLE stacks report busy/denied in prose. +const OPEN_FAILED_NAMES: readonly string[] = ['NetworkError', 'InvalidStateError', 'NotAllowedError'] +const OPEN_FAILED_RE = /\bbusy\b|in use|access denied|permission/i + +const IN_USE_HELP: KeyboardErrorHelp = { + title: 'Couldn’t open the device', + hint: 'Another app may be using it — close other configurator software, then try again.', +} + +export function explainKeyboardError(e: KeyboardError): KeyboardErrorHelp { + return match(e) + .with({ type: 'rynk' }, x => ({ + title: 'The keyboard rejected the request', + hint: `Error code: ${x.code}.`, + })) + .with({ type: 'transport' }, (x): KeyboardErrorHelp => { + if (x.cause instanceof Error && x.cause.message.includes('timed out')) { + return { + title: 'The keyboard didn’t respond', + hint: 'It may not be running RMK firmware, or another app is holding the connection.', + } + } + return { + title: 'Connection lost', + hint: 'The keyboard was unplugged, powered off, or went out of range.', + } + }) + .with({ type: 'invalid' }, x => ({ title: x.cause })) + .with({ type: 'unknown' }, (x): KeyboardErrorHelp => { + if (!(x.cause instanceof Error)) return { title: 'Connection failed' } + if (OPEN_FAILED_NAMES.includes(x.cause.name) || OPEN_FAILED_RE.test(x.cause.message)) { + return IN_USE_HELP + } + return { title: 'Connection failed', hint: x.cause.message || undefined } + }) + .exhaustive() +} diff --git a/src/stores/keyboard/index.ts b/src/stores/keyboard/index.ts index 2488212..a0ca380 100644 --- a/src/stores/keyboard/index.ts +++ b/src/stores/keyboard/index.ts @@ -1,5 +1,5 @@ -export { describeKeyboardError, toKeyboardError } from './errors' -export type { KeyboardError } from './errors' +export { describeKeyboardError, explainKeyboardError, toKeyboardError } from './errors' +export type { KeyboardError, KeyboardErrorHelp } from './errors' export { keyboardStore } from './keyboard.svelte' export type { ConnectionPhase, diff --git a/src/stores/keyboard/keyboard.svelte.ts b/src/stores/keyboard/keyboard.svelte.ts index 2954d55..a5bff2f 100644 --- a/src/stores/keyboard/keyboard.svelte.ts +++ b/src/stores/keyboard/keyboard.svelte.ts @@ -17,8 +17,9 @@ import type { KeyboardError } from './errors' import type { ConnectionState, KeyboardConfig, KeyboardDevice, KeyboardStatus } from './types' import { err, errAsync, ResultAsync } from 'neverthrow' import { match, P } from 'ts-pattern' +import { toast } from '../../lib/toast.svelte' import { connectClient } from '../../rynk' -import { toKeyboardError } from './errors' +import { explainKeyboardError, toKeyboardError } from './errors' const session = { client: null as RynkClient | null, @@ -218,6 +219,11 @@ class KeyboardStoreClass { /// skips awaiting it — awaiting your own promise deadlocks. private async handleDeath(client: RynkClient, cause: KeyboardError, fromTopicLoop: boolean): Promise { if (session.client !== client) return + // The one death the connect screen cannot report: the session was up and + // ended on its own, so the user lands back there with no attempt of their + // own to blame the failure on. + const help = explainKeyboardError(cause) + toast.error(help.title, help.hint) await this.teardown({ phase: 'error', label: this.#connection?.label ?? '', cause }, fromTopicLoop) } From c7167fa019c65b4f1eff7cde6f4492edda223672 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Tue, 4 Aug 2026 22:21:59 +0800 Subject: [PATCH 2/3] feat(usb): use WebUSB to replace WebSerial Signed-off-by: Haobo Gu --- README.md | 2 +- WEBUSB.md | 82 +++++ docs/src/README.md | 6 +- docs/src/SUMMARY.md | 2 +- docs/src/integration.md | 14 +- docs/src/native-transports/ble.md | 2 +- docs/src/native-transports/serial.md | 148 -------- docs/src/native-transports/usb.md | 101 ++++++ docs/src/rmk-gui-transport.md | 76 ++-- docs/src/rynk-wasm/README.md | 16 +- docs/src/rynk-wasm/client.md | 4 +- docs/src/rynk-wasm/js-byte-link.md | 465 +++++++++---------------- docs/src/rynk-wasm/lifecycle.md | 58 +-- docs/src/rynk-wasm/transport.md | 6 +- docs/src/rynk/README.md | 8 +- docs/src/rynk/architecture.md | 4 +- docs/src/rynk/protocol.md | 2 +- package.json | 2 +- pnpm-lock.yaml | 12 +- src-tauri/Cargo.toml | 13 +- src-tauri/src/main.rs | 6 +- src-tauri/src/transport/mod.rs | 2 +- src-tauri/src/transport/serial.rs | 40 --- src-tauri/src/transport/usb.rs | 48 +++ src/components/LogoCard.svelte | 2 +- src/components/TopBar.svelte | 2 +- src/lib/device-names.ts | 33 -- src/lib/toast.svelte.ts | 21 +- src/pages/Connect.svelte | 42 ++- src/rynk/core.test.ts | 34 +- src/rynk/core.ts | 23 +- src/rynk/index.ts | 73 ++-- src/rynk/tauri.ts | 10 +- src/rynk/web.ts | 221 ++++++++---- src/stores/devices.svelte.ts | 56 +-- src/stores/keyboard/errors.test.ts | 14 +- src/stores/keyboard/errors.ts | 21 +- src/stores/keyboard/index.ts | 2 +- src/stores/keyboard/keyboard.svelte.ts | 8 +- src/stores/keyboard/keyboard.test.ts | 4 +- src/vite-env.d.ts | 2 +- tsconfig.json | 2 +- 42 files changed, 823 insertions(+), 866 deletions(-) create mode 100644 WEBUSB.md delete mode 100644 docs/src/native-transports/serial.md create mode 100644 docs/src/native-transports/usb.md delete mode 100644 src-tauri/src/transport/serial.rs create mode 100644 src-tauri/src/transport/usb.rs delete mode 100644 src/lib/device-names.ts diff --git a/README.md b/README.md index d778b12..3305e8c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ and macOS 10.15+ (intel/apple). - Based on Rust and Tauri2 frameworks. - Concise and modern user interface, built with Svelte 5 and Tailwind CSS 4. -- Talks the rynk protocol over USB serial, BLE, and Web Serial. +- Talks the rynk protocol over raw USB (WebUSB in the browser), BLE, and WebHID. - Support for Windows, macOS, and Linux. ## Development diff --git a/WEBUSB.md b/WEBUSB.md new file mode 100644 index 0000000..67dbc7c --- /dev/null +++ b/WEBUSB.md @@ -0,0 +1,82 @@ +# USB 传输替换方案:WebUSB + Vendor Bulk 接口 + +| | | +|---|---| +| **状态** | 定稿(2026-08-04 评审后更新) | +| **范围** | rmk 固件 USB 描述符与 rynk 传输;rmk 仓库 `rynk/` host 传输 crate;rmk-gui Web/Tauri 两侧的 USB 通路 | + +--- + +## 1. 背景 + +rmk-gui 与固件之间说 rynk 协议(COBS 分帧字节流),USB 通路现状是两条:浏览器走 Web Serial 对固件的 CDC-ACM 接口;Tauri 经上游 `rynk-serial` crate(serialport)对同一接口。HID vendor collection(usage page 0xFF14,rmk#1022 从 Via/Vial 的 0xFF60 挪开)是给蓝牙场景的 WebHID 通路。 + +这套现状有四个结构性问题,都出在 CDC-ACM 上,且每一个都已在代码里付出了代价: + +1. **端口被占用,串口枚举一身泥**:ACM 端口是系统串口,ModemManager、终端工具、flash 脚本都可能抓走它,产生一类"连不上且原因不明"的故障。`rynk-serial` 为串口枚举背着 macOS `tty.`/`cu.` 去重和 serial marker 前缀匹配;`usb_log` + rynk 双 CDC 会把一台键盘发现成两台。打开 CDC 端口还会翻转 DTR(复位部分 MCU),discovery 因此被迫"只读不开",连接时得重新枚举一遍才能把 path 变回设备。 +2. **拿不到设备名**:Web Serial 的 `getInfo()` 规范上只暴露 VID/PID([WICG/serial#175](https://github.com/WICG/serial/issues/175) 悬而未决)。GUI 为绕这个缺口已经养出一套补丁机器:`device-names.ts` 的 localStorage 记名、握手成功后回写、discover 里 remembered → HID 同胞设备 → 裸 `USB 4c4b:4644` 的三级 fallback。 +3. **Android 不可用**:Android Chrome 不支持 Web Serial 和 WebHID,手机浏览器完全无法配置 USB 键盘;WebUSB 是 Android 上唯一可用的设备 API。 +4. **端点开销**:CDC 占 2 个接口 + IAD + 3 个端点(2 IN + 1 OUT)。nRF52840 只有 EP1–7 共 7 个 IN 端点,全家桶配置(keyboard + composite + steno + `usb_log` + rynk)正好 7/7 打满;vendor bulk 接口只要 1 接口 + 2 端点,省 1 个 IN 和约 40 字节配置描述符。 + +## 2. 目标与时机 + +固件新增一个 vendor-specific bulk 接口承载 rynk 字节流;浏览器用 WebUSB、Tauri 用 raw USB(nusb)连接它。授权即可读 USB 描述符的产品名与序列号(包括握手失败的设备);rynk 的 CDC-ACM 传输移除。 + +**必须赶在 v0.9 发布前落地。** rynk 未随任何正式版发布(最新 tag `rmk-v0.8.2` 早于 rynk 合入),此时替换,CDC 传输等于从未发布过,"旧固件不被新 GUI 支持"只影响手上的开发板,兼容性代价为零。拖过 v0.9,就要承担真实的发布顺序与用户升级窗口(见风险 4)。 + +## 3. 设计决策 + +1. **替换而非并存**:rynk 的 CDC-ACM 传输彻底移除,不保留 feature flag——保留它意味着占用问题与端点开销都还在,且两条 USB 通路永远都要维护。代价是 Tauri 侧必须同步迁移到 raw USB。USB 日志不受影响:`usb_log` 走 `embassy-usb-logger` 自己的通道,与 rynk 传输无关。 +2. **不复用现有接口跑 WebUSB**:HID 是 WebUSB 的 protected class,规范禁止 claim;CDC 接口在 macOS/Windows 上被内核驱动(AppleUSBACM / usbser.sys)占有,claim 失败。必须是新的 vendor 接口。 +3. **按接口三元组识别设备,不再依赖 serial number 标记**:vendor 接口固定 `class=0xFF, subclass=0x52 ('R'), protocol=0x52`(选值避开 ADB/fastboot 的 `0x42` 事实标准 subclass 空间;发布后冻结)。WebUSB 的 picker filter 与 nusb 枚举都能按这三元组精确匹配,与 VID/PID 解耦,自定义 VID/PID 的键盘无需任何额外配置即可被发现。serial number 里的 `rynk:` 前缀保留为纯信息用途(`lsusb`/系统报告可辨识 RMK 设备),发现不再依赖它;固件里 "so hosts can identify RMK devices cheaply" 的注释随之改掉。 +4. **GUI 直接迁移,不做过渡**:USB 通路只有 WebUSB/nusb 一条,Web Serial 与 serialport 代码整体删除。换来的是 GUI 里没有双路径分支、没有次级入口,连接页保持单一心智。 +5. **原生传输仍是 rmk 仓库的 host crate**:native USB 传输不属于 rmk-gui——现状的 `rynk-serial` 就是 rmk 仓库 `rynk/` 下实现 `RynkDevice` 的 crate,rmk-gui 只是 git 依赖加 40 行胶水。迁移形态保持对称:rmk 仓库新增 `rynk/rynk-usb`(nusb + `RynkDevice`,与 `rynk-ble` 并列),删除 `rynk-serial`,rmk-gui 换依赖。固件接口与 host crate 同仓同步出,不存在跨仓发布时序。nusb 纯 Rust、异步、免 libusb,顺带消掉 serialport 的 libudev/版本约束。 + +## 4. 详细设计 + +### 固件接口 + +- 单一 vendor 接口:`bInterfaceClass=0xFF, bInterfaceSubClass=0x52, bInterfaceProtocol=0x52`,一对 bulk endpoint(FS 64B / HS 512B)。 +- 字节流与 CDC 一致:COBS 分帧,`0x00` 定界,rynk 协议不感知传输差异。发送侧整帧写入、长度为 wMaxPacketSize 整数倍时补 ZLP——现有 `RynkUsbTx` 逻辑原样平移。 +- **读侧契约差异**:raw bulk OUT 的 `read` 要求缓冲区 ≥ wMaxPacketSize,而 `run_session` 用 `df.tail()` 读、尾部空间可小于一包。现在是 CDC `BufferedReceiver` 的一包 scratch 在兜底;新传输保留同样的 buffered 适配层(照搬现有 `RX_BUF` 模式)。 +- **MS OS 2.0 descriptors**:`Builder::msos_descriptor` 声明 header,vendor function 上用 `FunctionBuilder::msos_feature` 挂 `WINUSB` compatible ID + `DeviceInterfaceGUIDs` registry property。复合设备中 compatible ID 落在正确 function 上这件事由 embassy-usb 的 function subset API 结构性保证。 +- **iSerialNumber 改为 per-unit**:现状 USB 构建的 serial 是全型号相同的构建串,`serialNumber` 区分不了两台同型号键盘,Chrome 的 WebUSB 授权持久化也按 VID/PID/serial 记设备。发现已与 serial 解耦(决策 3),正好换成芯片 UID(nRF BLE 构建已在用 `get_serial_number()`);Vial 构建的 `vial:` magic 仍需留在 serial 里,UID 以字段并存,顺序尊重 BLE 长度截断。 +- **builder 静态缓冲扩容**:`usb/mod.rs` 里 `BOS_DESC`/`MSOS_DESC` 现各 16 字节。MS OS 2.0 descriptor set 约 178 字节(MSOS_DESC → 256);BOS 需容纳 28 字节 platform capability,加 WebUSB capability 再 +24(BOS_DESC → 64)。 +- 可选:BOS WebUSB capability + landing page URL。注意桌面 Chrome 已移除插入设备时的 landing page 通知,该体验仅 Android 生效,不做任何 UX 依赖。 + +### Web 传输(rmk-gui) + +`src/rynk/web-usb.ts` 新增 `WebUsbLink`,结构同 `WebHidLink`: + +- `requestDevice({ filters: [{ classCode: 0xFF, subclassCode: 0x52, protocolCode: 0x52 }] })`; +- `open() → selectConfiguration(1) → claimInterface(n)`(n 按三元组匹配,不硬编码);接收侧 `transferIn` 循环推入现有 `BufferedLink`(长度取 wMaxPacketSize 整数倍,否则整包到达报 overflow),发送侧 `transferOut` 直写; +- **pump 先行**:`open()` 后立刻挂起 `transferIn` 循环,再发 probe。bulk 没有 DTR,固件感知不到 host 离开(现状 CDC 同样不用 DTR,非新问题);上个会话悄悄消失时,固件的 service loop 可能停在一笔没人读的 topic 写上,先读才能排掉它,probe 会跳过这些 stale 帧。顺序颠倒的症状是"第二次连接永远超时"; +- 设备名取 `device.productName`,授权即持久可读;`device.serialNumber` 可区分两台同型号键盘,取代现在靠列表位置的临时办法; +- 命名补丁机器整体删除:`device-names.ts`、devices store 的握手回写、discover 的三级 fallback,连同 Web Serial 传输一起; +- `navigator.usb` 的 `disconnect` 事件触发 `end()`,与现有断链→teardown 流程衔接; +- 连接流程沿用现有的空闲看门狗与 open deadline,无需新超时逻辑。 + +### 原生传输(rmk 仓库 + Tauri) + +- rmk 仓库新增 `rynk/rynk-usb`:nusb 枚举设备 → 匹配接口三元组 → claim → bulk in/out,实现 `RynkDevice`(label 取描述符 product string);`rynk-serial` 删除。 +- **测试迁移先行**:`rynk-serial` 的 PTY `scripted_firmware` 握手测试是唯一在真实字节流上验证 `connect()` 的测试,删除 crate 前先搬进 rynk core,用内存 duplex 跑。 +- rmk-gui src-tauri:`serial.rs` → `usb.rs`,命令改为 `rynk_discover_usb` / `rynk_connect_usb`;依赖从 `rynk-serial` 换成 `rynk-usb`。发现不再需要"只读不开"的绕路——raw USB 枚举读描述符本来就不打开设备,也没有 DTR 语义。 + +### 影响范围 + +USB 通路整体切换,旧固件(CDC)不再被新 GUI 支持(时机见第 2 节,代价为零的窗口)。蓝牙通路(WebHID / btleplug)与 qemu 测试夹具(TCP)不受本方案影响。 + +## 5. 工程集成 + +- **rmk 固件**:vendor 接口 + MS OS 2.0 descriptors + builder 缓冲扩容;移除 rynk 的 CDC 传输(`usb_log` 独立,不受影响);rynk service 挂接新传输(含读侧一包 scratch 适配);serial 前缀注释修正。 +- **rmk host crates**:新增 `rynk/rynk-usb`,删除 `rynk-serial`,握手测试先迁入 rynk core。 +- **rmk-gui**:新增 `web-usb.ts` 传输并入 discover;删除 Web Serial 传输与整套命名补丁(`device-names.ts` 等);src-tauri 换依赖、`serial.rs` → `usb.rs`。 +- 文档:`docs/src/native-transports` 与 `rmk-gui-transport.md` 增补 WebUSB/nusb 通路。 + +## 6. 风险 + +1. **Windows 上 MS OS 2.0 是两条通路的单点**:Chrome(WebUSB)只能打开绑了 WinUSB 的接口,nusb 同样依赖 WinUSB 绑定——描述符不生效则 Windows 全平台不可用。首插体验在 Windows 10/11 实机验证优先级最高;per-function 落点已由 embassy-usb 保证,残余风险在 IAD/接口序号后续变动。 +2. **Windows 描述符缓存**:MS OS descriptor 按 VID/PID(+bcdDevice)首次查询后缓存于注册表,后续修改描述符不生效。不止开发期:用户 toggling `usb_log`/steno 等 feature 会改接口布局,同 VID/PID/bcdDevice 撞旧缓存,WinUSB 绑错或绑不上,表现为"重刷固件后 Windows 连不上"。`bcdDevice` 不绑定 RMK 版本号,由接口布局派生(布局哈希取低字节);开发期清缓存(`usbflags`)写进固件侧开发文档。 +3. **Linux 权限**:usbfs 节点默认 root 属主,Chrome(WebUSB)与 Tauri(nusb)都需要 udev 规则才能访问——并非新增负担,现状的 CDC 同样要手动加 `dialout` 组,WebHID 蓝牙通路本就需要 hidraw 规则(Vial 有同样先例)。规则按接口三元组匹配即可覆盖任意 VID/PID:`SUBSYSTEM=="usb", ENV{ID_USB_INTERFACES}=="*:ff5252:*", TAG+="uaccess"`;随文档与 Tauri 打包分发。 +4. **时机滑落**:若未能赶在 v0.9 发布前合入,退化为原发布顺序问题——固件版本必须先行,未升级固件的用户在 USB tab 什么都看不到,连接页空态文案要指向固件升级。 +5. **Android 实机验证**:claim vendor 接口按接口进行,理论上不碰 HID、打字不受影响——但 Android 支持是本方案的头部收益,需实机确认打字不断流,并验证系统 USB 授权弹窗的流程。 diff --git a/docs/src/README.md b/docs/src/README.md index 1983f86..d383762 100644 --- a/docs/src/README.md +++ b/docs/src/README.md @@ -25,8 +25,8 @@ The protocol is: about a message's shape. - **Versioned** — a `GetVersion` handshake establishes compatibility; major mismatches are hard-rejected, same-major minors connect with a warning. -- **Transport-agnostic** — the same `Client` drives USB serial, BLE GATT, - and browser (Web Serial / WebHID) transports through a common +- **Transport-agnostic** — the same `Client` drives raw USB, BLE GATT, + and browser (WebUSB / WebHID) transports through a common `embedded-io-async` byte-link interface. ## Crate Map @@ -35,7 +35,7 @@ The protocol is: |-------|------|--------| | `rynk` | Core protocol client: framing, handshake, typed API | `no_std`-compatible | | `rynk-wasm` | Browser-facing WASM client (wasm-pack) | `wasm32-unknown-unknown` | -| `rynk-serial` | USB CDC-ACM serial transport | native (tokio) | +| `rynk-usb` | Raw-USB vendor bulk transport (nusb) | native (tokio) | | `rynk-ble` | BLE GATT transport | native (bluest) | | `rynk-kle` | KLE/Vial JSON ↔ RMK layout conversion | native + WASM | diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 870ce5f..56b07a7 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -20,7 +20,7 @@ # Native Transports -- [Serial (USB CDC)](./native-transports/serial.md) +- [USB (Vendor Bulk)](./native-transports/usb.md) - [BLE (GATT)](./native-transports/ble.md) # Tooling & Integration diff --git a/docs/src/integration.md b/docs/src/integration.md index e89becb..63c1570 100644 --- a/docs/src/integration.md +++ b/docs/src/integration.md @@ -6,7 +6,7 @@ Source: `rynk/rynk-wasm/README.md`, `rynk/rynk-wasm/index.html` rmk-gui integrates `rynk-wasm` as its core communication layer with RMK keyboards. The split is deliberate: the browser/Tauri page owns the transport -(Web Serial or WebHID), and `rynk-wasm` owns the protocol state machine. +(WebUSB or WebHID), and `rynk-wasm` owns the protocol state machine. Browser permissions, chooser UI, stream locks, and hot-plug events stay in JS; request/response typing, topic handling, and protocol validation stay in Rust. @@ -18,7 +18,7 @@ canonical example before building rmk-gui's integration layer. ```text rmk-gui (Tauri/SolidJS) │ - ├── User gesture → navigator.serial.requestPort() / navigator.hid.requestDevice() + ├── User gesture → navigator.usb.requestDevice() / navigator.hid.requestDevice() ├── JsByteLink { send, recv, close } — owns browser transport ├── rynk-wasm pkg (wasm-pack output) │ ├── connect(link, label?) → RynkClient @@ -49,7 +49,7 @@ The object passed to `connect(link)` only needs this shape: The connect flow must begin inside a user gesture (button click) because Web Serial and WebHID both require one for `requestPort()` / `requestDevice()`. -1. **User clicks connect button** (user gesture required for Web Serial/WebHID). +1. **User clicks connect button** (user gesture required for WebUSB/WebHID). 2. **Open browser transport, create `JsByteLink`.** 3. **(Optional) Probe version:** `link.probeVersion()` returns `{ major, minor }`. 4. **Load version-matched wasm:** `loadCore(major)` dynamically imports @@ -145,7 +145,7 @@ async function teardown() { ### Auto-reconnect -Use `navigator.serial.getPorts()` and `navigator.hid.getDevices()` to find +Use `navigator.usb.getDevices()` and `navigator.hid.getDevices()` to find previously granted devices. Listen to the `connect` / `disconnect` events on both APIs: @@ -155,9 +155,9 @@ both APIs: while connected; tear down the active session. ```js -navigator.serial?.addEventListener?.('connect', () => { if (!connected) autoConnect() }) +navigator.usb?.addEventListener?.('connect', () => { if (!connected) autoConnect() }) navigator.hid?.addEventListener?.('connect', () => { if (!connected) autoConnect() }) -navigator.serial?.addEventListener?.('disconnect', () => { if (connected) teardown() }) +navigator.usb?.addEventListener?.('disconnect', () => { if (connected) teardown() }) navigator.hid?.addEventListener?.('disconnect', () => { if (connected) teardown() }) ``` @@ -232,5 +232,5 @@ end-of-data — parse the macro encoding itself for termination, and iterate - Capability-based show/hide of features Study it before building rmk-gui's integration layer. The `index.html` file -contains complete Web Serial and WebHID `JsByteLink` implementations that can +contains complete WebUSB and WebHID `JsByteLink` implementations that can serve as a starting point for the transport layer. diff --git a/docs/src/native-transports/ble.md b/docs/src/native-transports/ble.md index 7c508aa..f5bae19 100644 --- a/docs/src/native-transports/ble.md +++ b/docs/src/native-transports/ble.md @@ -138,4 +138,4 @@ impl RynkDevice for BleDevice { A failure means the device is gone or isn't a Rynk keyboard. The full connect flow mirrors the serial transport; see -[Serial (USB CDC)](./serial.md). +[USB (Vendor Bulk)](./usb.md). diff --git a/docs/src/native-transports/serial.md b/docs/src/native-transports/serial.md deleted file mode 100644 index 857b117..0000000 --- a/docs/src/native-transports/serial.md +++ /dev/null @@ -1,148 +0,0 @@ -# Serial (USB CDC) - -Source: `rynk/rynk-serial/src/lib.rs` - -## Overview - -`rynk-serial` is a USB CDC-ACM serial transport for the Rynk host library, -built on [`tokio-serial`](https://docs.rs/tokio-serial). It discovers Rynk -keyboards by the `RYNK_SERIAL_MAGIC` marker — the string `"rynk:"` — that the -firmware prepends to the USB serial number. This is an immutable tag the -firmware emits regardless of the user-configured VID or serial string, so a host -can pick Rynk keyboards out of all serial ports without probing every device. - -The marker is the serial transport's counterpart to BLE's service UUID: it -identifies a device as Rynk before connecting. - -## Discovery - -`SerialDevice::discover()` lists the USB CDC ports whose serial number carries -the magic marker, returning one `SerialDevice` per keyboard. - -```rust -pub async fn discover() -> Result, RynkHostError> -``` - -Discovery deliberately never opens a port. The OS caches the serial string at -USB enumeration time, so the marker can be read on Windows, macOS, and Linux -without touching the device. This matters because opening a CDC port toggles -DTR, which resets some MCUs — only the device the user picks is opened, exactly -once, during `connect()`. - -### macOS deduplication - -macOS exposes a single USB CDC device as both `/dev/cu.*` and `/dev/tty.*`. -`rynk_serial_ports()` keeps only the `cu.*` node so the picker does not list the -same keyboard twice. Other platforms have no `cu.*` sibling, so the filter is a -no-op there. - -## SerialDevice - -```rust -pub struct SerialDevice { - pub path: String, - pub name: Option, -} -``` - -| Field | Type | Description | -|--------|------------------|--------------------------------------------------| -| `path` | `String` | Port path (e.g. `/dev/cu.usbmodem`, `COM3`). | -| `name` | `Option` | USB product string from the device descriptor, if it carried one. | - -### Methods - -- **`label()`** — returns the USB product name, falling back to the port path - when the descriptor carried none. This is the display label a device picker - shows. -- **`open()`** — opens the port at 115200 baud, then clears the input buffer - (`ClearBuffer::Input`) to discard stale bytes left over from a prior session. - A device unplugged since discovery surfaces as a normal `RynkHostError`. - -### Why 115200? - -```rust -const CDC_BAUD_RATE: u32 = 115_200; -``` - -USB CDC-ACM devices ignore the baud rate — it is required by the serial API but -has no effect on the USB endpoint. The constant exists only to satisfy the -`tokio_serial::new` builder. - -## SerialTransport - -```rust -pub struct SerialTransport { - io: FromTokio, -} -``` - -`SerialTransport` wraps `embedded_io_adapters::tokio_1::FromTokio`, -adapting a `tokio-serial` stream to Rynk's `Read + Write` traits. Dropping the -owning `Client` ends the Rynk session only; the keyboard stays connected and -usable. - -```rust -impl rynk::io::ErrorType for SerialTransport { - type Error = std::io::Error; -} - -impl Read for SerialTransport { /* delegates to self.io.read */ } -impl Write for SerialTransport { /* delegates to self.io.write; flush is a no-op */ } -``` - -## RynkDevice Implementation - -`SerialDevice` implements `RynkDevice`: - -```rust -impl RynkDevice for SerialDevice { - type Transport = SerialTransport; - - fn label(&self) -> String { /* product name or port path */ } - - async fn open(self) -> Result { /* open + clear */ } -} -``` - -- `type Transport = SerialTransport` — the byte link `Client::connect` consumes. -- `open()` opens the port and clears the input buffer. A device unplugged since - discovery surfaces as a normal `RynkHostError`, not a panic. - -The full connect flow (`label` → `open` → `connect`) is driven by the -`RynkDevice` trait; see [Architecture](../rynk/architecture.md). - -## Native Only - -This crate is native-only (not WASM). It depends on `tokio-serial` and the -`embedded-io-adapters` `tokio-1` feature, neither of which target -`wasm32-unknown-unknown`. For browser serial access, use -[rynk-wasm Web Serial](../rynk-wasm/js-byte-link.md). - -## Example - -From `rynk/README.md` — discover marked Rynk keyboards, pick one, and open it -(the handshake runs inside `connect`): - -```rust,no_run -# async fn run() -> Result<(), Box> { -// Discover marked Rynk keyboards, pick one, and open it (the handshake runs -// inside `connect`). `rynk-ble` mirrors this flow. -use rynk::RynkDevice; -use rynk_serial::SerialDevice; -let device = SerialDevice::discover() - .await? - .into_iter() - .next() - .ok_or("no Rynk keyboard found")?; -let mut client = device.connect().await?; - -let caps = *client.capabilities(); -println!("{}×{}×{} keymap", caps.num_layers, caps.num_rows, caps.num_cols); - -let key = client.get_key(0, 0, 0).await?; -println!("L0(0,0) = {key:?}"); -# Ok(()) } -``` - -`rynk-ble` mirrors this flow; see [BLE (GATT)](./ble.md). diff --git a/docs/src/native-transports/usb.md b/docs/src/native-transports/usb.md new file mode 100644 index 0000000..200fc48 --- /dev/null +++ b/docs/src/native-transports/usb.md @@ -0,0 +1,101 @@ +# USB (Vendor Bulk) + +Source: `rynk/rynk-usb/src/lib.rs` + +## Overview + +`rynk-usb` is a raw-USB transport for the Rynk host library, built on +[`nusb`](https://docs.rs/nusb) (pure Rust, async, no libusb). It talks to the +firmware's vendor bulk interface and recognises Rynk keyboards by the interface +class triple the firmware advertises: + +```rust +// rmk-types::protocol::rynk +pub const RYNK_USB_INTERFACE_CLASS: u8 = 0xFF; +pub const RYNK_USB_INTERFACE_SUBCLASS: u8 = 0x52; +pub const RYNK_USB_INTERFACE_PROTOCOL: u8 = 0x52; +``` + +The triple is the USB counterpart to BLE's service UUID: it identifies a device +as Rynk before connecting, independent of the user-configured VID/PID. There is +no serial port, so nothing an OS serial driver or another tool can sit on, and +no DTR to reset an MCU. + +## Discovery + +```rust +pub async fn discover() -> Result, RynkHostError> +``` + +`UsbDevice::discover()` walks `nusb::list_devices()` and keeps every device +carrying the vendor interface — one `UsbDevice` per keyboard. Enumeration reads +cached descriptors and never opens a device; the chosen device is opened once, +by `connect()`. + +### Identity + +`UsbDevice::id()` returns the `nusb::DeviceId` — stable across enumerations +while the keyboard stays plugged in. It plays the role the serial transport's +port path used to: a picked entry is matched back to a fresh `discover()` list +at connect time. + +## RynkDevice Implementation + +```rust +impl RynkDevice for UsbDevice { + type Read = UsbReader; + type Write = UsbWriter; + + fn label(&self) -> String { /* product string, or "USB vvvv:pppp" */ } + + async fn open(self) -> Result<(UsbReader, UsbWriter), RynkHostError> { + /* open → claim vendor interface → resolve the bulk endpoint pair */ + } +} +``` + +- **`label()`** — the USB product string, falling back to the numeric ids when + the descriptor carried none. Available before connecting, so a picker can + name even a keyboard that will fail the handshake. +- **`open()`** — opens the device, claims the vendor interface, and finds its + bulk IN/OUT endpoints. A device unplugged since discovery surfaces as a + normal `RynkHostError`. + +## The Halves + +- **`UsbReader`** — `nusb`'s `EndpointRead` transfer pump behind the + `embedded-io` tokio adapter, reading 4096-byte transfers (a whole Rynk frame, + and a multiple of both Full- and High-Speed packet sizes). Zero-length + packets — the firmware's transfer delimiters — are absorbed by the pump. +- **`UsbWriter`** — one bulk transfer per `write`, awaited to completion. The + Rynk driver never flushes, so a buffering writer that submits only full + transfers would strand frames. + +Dropping the halves (with the owning session) ends the Rynk **session** only: +the keyboard stays connected and usable. + +## Native Only + +This crate is native-only (not WASM). Browsers reach the same vendor interface +through WebUSB — rmk-gui's `WebUsbLink` speaks to it with the same class-triple +filter. + +## Example + +```rust,no_run +# async fn run() -> Result<(), Box> { +use rynk::RynkDevice; +use rynk_usb::UsbDevice; +let device = UsbDevice::discover() + .await? + .into_iter() + .next() + .ok_or("no Rynk keyboard found")?; +let mut client = device.connect().await?; + +let caps = *client.capabilities(); +println!("{}×{}×{} keymap", caps.num_layers, caps.num_rows, caps.num_cols); +# Ok(()) } +``` + +`rynk-ble` mirrors this flow; see [BLE (GATT)](./ble.md). diff --git a/docs/src/rmk-gui-transport.md b/docs/src/rmk-gui-transport.md index 776cffb..701cb0b 100644 --- a/docs/src/rmk-gui-transport.md +++ b/docs/src/rmk-gui-transport.md @@ -21,16 +21,17 @@ hands it a `JsByteLink`. The backend's only job is to produce that link. │ └──────────────┬──────────────────┘ │ │ │ JsByteLink { send, recv, close } │ │ ┌──────────────┴──────────────────┐ │ -│ │ TauriByteLink / WebByteLink │ │ +│ │ TauriByteLink / WebUsbLink / │ │ +│ │ WebHidLink │ │ │ └──────────────┬──────────────────┘ │ └─────────────────┼───────────────────────────────────────────┘ │ ┌─────────────┴──────────────┐ │ Tauri backend (Rust) │ │ Web (browser) - │ invoke('rynk_send/recv') │ │ navigator.serial - │ ┌─ serial (tokio-serial) │ │ ┌─ WebByteLink - │ ├─ tcp (tokio TcpStream)│ │ └─ WebHID (planned) - │ └─ ble (btleplug) │ + │ invoke('rynk_send/recv') │ │ navigator.usb / navigator.hid + │ ┌─ usb (rynk-usb / nusb) │ │ ┌─ WebUsbLink (vendor bulk) + │ ├─ tcp (tokio TcpStream) │ │ └─ WebHidLink (BLE-bonded) + │ └─ ble (btleplug) │ └─────────────────────────────┘ ``` @@ -40,15 +41,15 @@ hands it a `JsByteLink`. The backend's only job is to produce that link. src-tauri/src/ ├── main.rs — fn main() + tauri::Builder + generate_handler └── transport/ - ├── mod.rs — Session model, spawn_tokio_io, rynk_send/recv/close - ├── serial.rs — SerialDeviceInfo, rynk_discover_serial, rynk_connect_serial + ├── mod.rs — Session model, spawn_tokio_io, rynk_pump, rynk_send/recv/close + ├── usb.rs — UsbDeviceInfo, rynk_discover_usb, rynk_connect_usb ├── tcp.rs — TcpDeviceInfo, rynk_discover_tcp, rynk_connect_tcp └── ble.rs — BleDeviceInfo, rynk_discover_ble, rynk_connect_ble src/rynk/ ├── index.ts — Unified discover() + connect API, isTauri() detection ├── tauri.ts — TauriByteLink + discover/connect helpers (invoke) -└── web.ts — WebByteLink (WebSerial) +└── web.ts — WebUsbLink (WebUSB) + WebHidLink (WebHID) ``` ## Tauri Commands @@ -58,10 +59,10 @@ Nine commands, all returning `Result` except `rynk_discover_tcp` | Command | Module | Purpose | |---------|--------|---------| -| `rynk_discover_serial` | `serial.rs` | List USB CDC ports with `rynk:` marker | +| `rynk_discover_usb` | `usb.rs` | List devices carrying the Rynk vendor interface triple | | `rynk_discover_ble` | `ble.rs` | Scan for BLE devices advertising Rynk service UUID | | `rynk_discover_tcp` | `tcp.rs` | Probe `127.0.0.1:7965` (dev-only, 300ms timeout) | -| `rynk_connect_serial` | `serial.rs` | Open serial port, clear input buffer | +| `rynk_connect_usb` | `usb.rs` | Open device, claim the vendor interface | | `rynk_connect_ble` | `ble.rs` | Connect + discover GATT + subscribe + spawn task | | `rynk_connect_tcp` | `tcp.rs` | Connect TCP, split read/write | | `rynk_send` | `mod.rs` | Write bytes to session (waits for write ack) | @@ -70,7 +71,7 @@ Nine commands, all returning `Result` except `rynk_discover_tcp` ## Session Model -Every connection — serial, TCP, or BLE — produces the same `Session` struct: +Every connection — USB, TCP, or BLE — produces the same `Session` struct: ```rust struct Session { @@ -103,9 +104,11 @@ write MUST commit the bytes"). Each transport spawns a `tokio::spawn` task that owns the transport and runs a `tokio::select!` loop: -- **Serial/TCP** (`spawn_tokio_io`): uses `tokio::io::split` to get separate +- **TCP** (`spawn_tokio_io`): uses `tokio::io::split` to get separate `AsyncRead` + `AsyncWrite` halves. The select loop reads from the read half and writes from the command channel. +- **USB** (`rynk_pump`): the same loop shape over the `embedded-io-async` + halves `rynk-usb` hands out. - **BLE** (`rynk_connect_ble`): uses btleplug's `notifications()` stream for reads and `peripheral.write()` for writes. Same select loop shape, different read source. @@ -120,21 +123,21 @@ When the transport reads `Ok(0)` or an error, the task sends `Vec::new()` The frontend's `TauriByteLink.recv()` returns `new Uint8Array(0)`, which `WasmTransport` interprets as EOF → `RynkHostError::Disconnected`. -## Serial Transport +## USB Transport ### Discovery -`rynk_discover_serial` calls `tokio_serial::available_ports()`, filters by -the `RYNK_SERIAL_MAGIC` (`"rynk:"`) marker in the USB serial number, and -dedupes macOS `cu.*` / `tty.*` pairs. Discovery never opens a port — opening -a CDC port toggles DTR, which resets some MCUs. +`rynk_discover_usb` calls `rynk_usb::UsbDevice::discover()`, which matches the +vendor interface class triple (`0xFF/0x52/0x52`) against every USB device — +VID/PID never enter into it. Enumeration reads cached descriptors and opens +nothing; the returned `id` is the `nusb::DeviceId` formatted as a string, and +the label is the descriptor's product string. ### Connect -`rynk_connect_serial` opens the port at 115200 baud (ignored by USB CDC), -clears the input buffer (`ClearBuffer::Input`) to discard stale bytes from a -prior session, then `tokio::io::split` the stream into read/write halves fed -to `spawn_tokio_io`. +`rynk_connect_usb` re-discovers and matches the id back to a device (the role +the serial transport's port path used to play), opens it, claims the vendor +interface, and feeds the bulk halves to `rynk_pump`. ## TCP Transport @@ -229,27 +232,32 @@ class TauriByteLink { } ``` -### WebByteLink (`src/rynk/web.ts`) +### WebUsbLink (`src/rynk/web.ts`) -Wraps `navigator.serial` into the same `JsByteLink` shape. One reader task -drains `port.readable` into an internal buffer; `recv()` returns buffered -chunks or waits; `close()` cancels reader/writer and releases locks. +Wraps `navigator.usb` into the same `JsByteLink` shape. The device is matched +and claimed by the vendor interface triple; a pump keeps a `transferIn` +pending from the moment the link exists (bulk has no DTR, so a previous +session's unread topic push is drained by the next session's first reads); +`transferOut` writes frames whole. `WebHidLink` is the same shape over the +vendor HID collection an OS-bonded Bluetooth keyboard exposes. ### Unified API (`src/rynk/index.ts`) `discover()` detects Tauri vs Web at runtime and returns `TransportInfo[]`: ```typescript -const isTauri = ... // from @tauri-apps/api/core - async function discover(): Promise { - if (!isTauri) return [] // Web: no enumeration - const [serials, bles, tcps] = await Promise.all([ - discoverSerial().catch(() => []), + if (!isTauri()) { + // Web: everything the user has already granted, no gesture needed. + const [usbs, hids] = await Promise.all([grantedUsbDevices(), grantedHidDevices()]) + // Map each into { kind, id, label, connect, handle } + } + const [usbs, bles, tcps] = await Promise.all([ + discoverUsb().catch(() => []), discoverBle().catch(() => []), discoverTcp().catch(() => []), ]) - // Map each into { kind, label, connect: () => Promise } + // Map each into { kind, id, label, connect: () => Promise } } ``` @@ -257,6 +265,6 @@ Each `TransportInfo` carries a `connect()` closure that returns a `ByteLink`. The caller passes this to `rynk-wasm`'s `core.connect(link, label)` — the protocol layer takes over from there. -Web mode has no enumeration (`navigator.serial.requestPort()` requires a user -gesture and opens a picker), so `connectWebSerialPort()` is a separate entry -point called from a click handler. +Granting a new device needs the browser's picker, which must run inside a user +gesture — `requestUsbDevice()` / `requestHidDevice()` are the click-handler +entry points; the grant then joins the same `discover()` list. diff --git a/docs/src/rynk-wasm/README.md b/docs/src/rynk-wasm/README.md index ae40711..dc0693c 100644 --- a/docs/src/rynk-wasm/README.md +++ b/docs/src/rynk-wasm/README.md @@ -5,13 +5,13 @@ JavaScript. This crate is the core of `rmk-gui` — every configuration and status call the application makes flows through it. -The browser page owns browser transports such as Web Serial and WebHID. The wasm +The browser page owns browser transports such as WebUSB and WebHID. The wasm package owns the Rynk protocol state machine. ## Architecture ```text -Web Serial / WebHID / another browser transport +WebUSB / WebHID / another browser transport -> JsByteLink { send, recv, close } -> transport::WasmTransport -> rynk::Client @@ -25,7 +25,7 @@ byte-stream interface (`JsByteLink`) that any browser transport can implement. ## Prerequisites -- A Chromium browser such as Chrome or Edge. Web Serial and WebHID are not +- A Chromium browser such as Chrome or Edge. WebUSB and WebHID are not available in Firefox or Safari. - Wasm target: `rustup target add wasm32-unknown-unknown` - Packager: `cargo install wasm-pack` @@ -35,11 +35,11 @@ byte-stream interface (`JsByteLink`) that any browser transport can implement. ```bash cd rynk/rynk-wasm wasm-pack build --target web # emits ./pkg/ with generated JS and .d.ts files -python3 -m http.server 8000 # localhost is a secure context for Web Serial / WebHID +python3 -m http.server 8000 # localhost is a secure context for WebUSB / WebHID ``` Open Chrome or Edge at `http://localhost:8000` and use `index.html` as the -reference shell. `localhost` is a secure context, which Web Serial and WebHID +reference shell. `localhost` is a secure context, which WebUSB and WebHID require — an IP address will not work. CI runs the same package build so binding generation is checked without @@ -68,7 +68,7 @@ import init, { connect } from './pkg/rynk_wasm.js' await init() -const link = await openSerialByteLink() +const link = await openUsbByteLink() const client = await connect(link) console.log('protocol', await client.get_version()) @@ -105,7 +105,7 @@ interface): A topic pump loop drives `next_event()` in a `for (;;)` loop until the await rejects with `Disconnected`. This mirrors the native -`Client::next_event()` pull used by `rynk-serial` and `rynk-ble`: +`Client::next_event()` pull used by `rynk-usb` and `rynk-ble`: ```js async function pumpTopics(client) { @@ -151,5 +151,5 @@ Dependencies (from `Cargo.toml`): `rynk` (with the `wasm` feature), - [WasmTransport](./transport.md) — how `JsByteLink` becomes `Read`/`Write` - [Lifecycle & Dead States](./lifecycle.md) — connect flow, cancelled reads, topic overflow, reconnect -- [JS Byte Link Implementations](./js-byte-link.md) — Web Serial and WebHID +- [JS Byte Link Implementations](./js-byte-link.md) — WebUSB and WebHID reference code diff --git a/docs/src/rynk-wasm/client.md b/docs/src/rynk-wasm/client.md index 69bab0d..c0521ad 100644 --- a/docs/src/rynk-wasm/client.md +++ b/docs/src/rynk-wasm/client.md @@ -15,7 +15,7 @@ pub struct RynkClient(Client); `RynkClient` wraps `Client`. Each method borrows the client for one `await` — JS must await one call before issuing the next. This is the same -single-borrow rule the native serial/BLE transports get from the compiler: the +single-borrow rule the native USB/BLE transports get from the compiler: the `&mut self` on every method serializes requests with no background task or shared state. @@ -33,7 +33,7 @@ pub async fn connect(link: JsByteLink, label: Option) -> Result browser transport */ }, - async recv() { /* browser transport -> Uint8Array; empty at EOF */ }, - async close() { /* release browser resources; idempotent */ }, +```ts +interface JsByteLink { + send: (frame: Uint8Array) => Promise + recv: () => Promise // empty at EOF + close: () => Promise // release browser resources; idempotent + readonly label: string } ``` -Only the JS code that opens and normalizes the browser transport differs. Web -Serial streams raw Rynk frame bytes directly. WebHID fragments each Rynk frame -into fixed 32-byte HID reports and reassembles them back into the clean byte -stream before WASM sees them. - -Both links also implement an optional `probeVersion()` method used before -`connect()` to select a version-matched wasm build (see -[Lifecycle & Dead States](./lifecycle.md)). +Only the code that opens and normalizes the browser transport differs. WebUSB +streams raw COBS-framed Rynk bytes over a bulk endpoint pair. WebHID fragments +each frame into fixed 32-byte HID reports — the zero padding decodes as empty +COBS frames and is discarded, so WASM sees the same clean byte stream either +way. No length-based reassembly is needed: `0x00` delimits frames. -## Web Serial (USB) +The version probe is not a link method. `probeVersion(link)` in +`src/rynk/core.ts` runs over any `JsByteLink` before the wasm build is chosen +(see [Lifecycle & Dead States](./lifecycle.md)). -Web Serial opens the keyboard's USB CDC-ACM serial port and streams raw Rynk -frame bytes over it. No framing adaptation is needed — the serial stream is -already a byte stream. +## BufferedLink -### Opening the port +Both links share one base class: a buffer that collects device→host bytes and +hands them to `recv()` a chunk at a time. -`navigator.serial.requestPort()` must be called inside a user gesture (a button -click). The port is then opened at 115200 baud: - -```js -port = await navigator.serial.requestPort(); -await port.open({ baudRate: 115200 }); -return link(port); +```ts +abstract class BufferedLink { + protected push(bytes: Uint8Array) // transport delivers bytes + protected end() // transport hit EOF; recv() returns empty + async recv(): Promise // parks until bytes or EOF +} ``` -Source: `rynk/rynk-wasm/index.html`, lines 326-331. +`push()` wakes a parked `recv()`; `end()` marks EOF so every later `recv()` +resolves to an empty chunk — which the WASM transport reads as +`RynkHostError::Disconnected`. -### Buffered link +## WebUSB (Vendor Bulk) -The reference implementation uses a buffered link: one reader task drains the -port into an `rx` buffer, and consumers read from `rx`. This supports the -pre-load version probe (which needs to read frames) and the `recv()` contract -simultaneously: +WebUSB claims the firmware's vendor bulk interface, recognized by the class +triple the firmware advertises — never by VID/PID: -```js -function link(port) { - const reader = port.readable.getReader() - let writer - try { - writer = port.writable.getWriter() - } - catch (e) { - reader.releaseLock() // don't leak the read lock if acquiring the writer fails - throw e - } - let rx = new Uint8Array(0) - let closed = false - let wake = null // resolve fn of a consumer waiting on `rx` - - const signal = () => { if (wake) { const w = wake; wake = null; w() } }; - - // Drain the port into rx until EOF. - (async () => { - try { - for (;;) { - const { value, done } = await reader.read() - if (done) break - if (value && value.length) { rx = concat(rx, value); signal() } - } - } - catch {} - closed = true - signal() - })() - - // Check rx and install wake synchronously to avoid missed wakeups. - async function ready() { - while (rx.length === 0 && !closed) await new Promise((res) => { wake = res }) - } - async function fill(n) { - while (rx.length < n) { - if (closed) throw new Error('port closed') - await ready() - } - } - async function readFrame() { - await fill(RYNK_HEADER) - const len = rx[3] | (rx[4] << 8) - await fill(RYNK_HEADER + len) - const f = rx.slice(0, RYNK_HEADER + len) - rx = rx.slice(RYNK_HEADER + len) - return { cmd: f[0] | (f[1] << 8), seq: f[2], payload: f.slice(RYNK_HEADER) } - } - - return { - // Probe GetVersion before loading wasm. - async probeVersion() { - await writer.write(frame(CMD_GET_VERSION, 1, new Uint8Array(0))) - let f - do { f = await readFrame() } while (f.cmd & RYNK_TOPIC_BIT) - if (f.payload.length < 3 || f.payload[0] !== 0x00) { - throw new Error(`bad version reply: [${f.payload}]`) - } - return { major: f.payload[1], minor: f.payload[2] } - }, - async send(f) { await writer.write(f) }, - async recv() { - await ready() - if (rx.length > 0) { const c = rx; rx = new Uint8Array(0); return c } - return new Uint8Array(0) - }, - async close() { - try { await reader.cancel() } - catch {} - try { await writer.abort() } - catch {} - try { await port.close() } - catch {} - }, - } -} +```ts +const RYNK_USB_CLASS = 0xFF +const RYNK_USB_SUBCLASS = 0x52 +const RYNK_USB_PROTOCOL = 0x52 ``` -Source: `rynk/rynk-wasm/index.html`, lines 98-172. +### Opening the device -### Method summary +`navigator.usb.requestDevice()` must be called inside a user gesture. The +filter matches the triple, so only Rynk keyboards appear in the chooser: -- **`probeVersion()`** — sends a `GetVersion` frame (`CMD_GET_VERSION = 0x0001`, - seq 1, empty body), reads frames until a non-topic reply arrives, parses the - version from the postcard `Result` payload. -- **`send(f)`** — `writer.write(f)`. The serial writer accepts a `Uint8Array` - directly. -- **`recv()`** — waits until `rx` has bytes or the port is closed, then returns - the entire buffered chunk. Returns `new Uint8Array(0)` at EOF. -- **`close()`** — cancels the reader, aborts the writer, closes the port. Each - step is wrapped in `try/catch` so a partially-open state still closes cleanly. +```ts +const device = await navigator.usb.requestDevice({ + filters: [{ classCode: 0xFF, subclassCode: 0x52, protocolCode: 0x52 }], +}) +``` -### Rynk frame constants +Already-granted keyboards need no gesture — `navigator.usb.getDevices()` +returns them (filtered by the same triple, read from cached descriptors), which +is how the app lists devices and reconnects on launch. The descriptor's +`productName` is readable the moment the grant exists, before any handshake. -The JS side needs these constants to build and parse raw frames (for the version -probe only — after `connect()`, the wasm client handles all framing): +Opening claims the interface and resolves the bulk endpoint pair from the +descriptors (never hardcoded): -```js -const RYNK_HEADER = 5 // cmd:u16 LE, seq:u8, len:u16 LE -const RYNK_TOPIC_BIT = 0x8000 // CMD high bit set: server-to-host topic push -const CMD_GET_VERSION = 0x0001 +```ts +await device.open() +if (device.configuration === null) + await device.selectConfiguration(device.configurations[0].configurationValue) +await device.claimInterface(interfaceNumber) ``` -## WebHID (BLE over OS HID) - -WebHID reaches the firmware's vendor HID report (`RynkHidService`) via the -existing OS HID link. This works for BLE keyboards that are already bonded at -the OS level — there is no pairing prompt. +### The pump starts before the first send + +`WebUsbLink`'s constructor immediately parks a `transferIn` on the IN +endpoint. Bulk has no DTR, so the firmware never learns that a previous +session's host vanished — it may be parked on a topic write no one read, and +only a pending IN transfer drains it. The version probe skips those stale +frames. Starting the pump late (after the first send) wedges exactly that +reconnect case. + +```ts +private async pump() { + while (!this.closed) { + const result = await this.device.transferIn(this.epIn, USB_READ_SIZE) + if (result.status === 'stall') { await this.device.clearHalt('in', this.epIn); continue } + if (result.data) this.push(new Uint8Array(result.data.buffer, ...)) + } + this.end() +} +``` -### Opening the device +`USB_READ_SIZE` is 4096 — a whole Rynk frame per transfer, and a multiple of +both Full- and High-Speed packet sizes. A read length that is not a multiple of +`wMaxPacketSize` errors the moment the device sends a full packet. -`navigator.hid.requestDevice()` must be called inside a user gesture. The -filter targets the Rynk vendor usage page `0xFF60` and usage `0x61`: +### Method summary -```js -const devs = await navigator.hid.requestDevice({ - filters: [{ usagePage: 0xFF60, usage: 0x61 }] -}); -if (!devs.length) throw new Error("no WebHID device chosen"); -device = devs[0]; -if (!device.opened) await device.open(); -return hidLink(device); -``` +- **`send(f)`** — one `transferOut` with the whole frame, copied into a fresh + buffer (the API wants plain `ArrayBuffer` backing). +- **`recv()`** — `BufferedLink`: returns buffered chunks, empty at EOF. An + unplug rejects the pending `transferIn`, which ends the pump and signals EOF. +- **`close()`** — releases the interface and closes the device, bounded by a + 1s race so a wedged stack cannot hold up the teardown; the pending + `transferIn` is aborted by the close. -Source: `rynk/rynk-wasm/index.html`, lines 333-340. +## WebHID (BLE over OS HID) -### 32-byte report framing +WebHID reaches the firmware's `RynkHidReport` via the existing OS HID link. +This works for BLE keyboards that are already bonded at the OS level — there +is no pairing prompt. -Each Rynk frame is fragmented into fixed 32-byte HID reports. The firmware's -`RynkHidService` uses report ID 0 and a fixed report size of 32 bytes: +### Opening the device -```js -const RYNK_HID_REPORT_SIZE = 32 // firmware RynkHidService report -``` +The filter targets Rynk's own vendor usage page. `0xFF14` is deliberate: +rmk-rs/rmk#1022 moved it off `0xFF60`, which Via/Vial keyboards use, so their +boards no longer match the chooser filter. -**Sending** — one Rynk frame is split into 32-byte reports, each padded to the -full report size with zeros: +```ts +const RYNK_HID_USAGE_PAGE = 0xFF14 +const RYNK_HID_USAGE = 0x61 -```js -async function sendFramed(bytes) { - for (let off = 0; off < bytes.length; off += N) { - const chunk = bytes.subarray(off, off + N) - const report = new Uint8Array(N) - report.set(chunk) - await device.sendReport(0, report) // report ID 0 - } -} +const devices = await navigator.hid.requestDevice({ + filters: [{ usagePage: RYNK_HID_USAGE_PAGE, usage: RYNK_HID_USAGE }], +}) ``` -Source: `rynk/rynk-wasm/index.html`, lines 210-217. +`navigator.hid.getDevices()` lists already-granted keyboards without a +gesture, filtered by the same usage on their `collections`. -### Reassembly - -**Receiving** — the `remaining` counter trims the final report's padding using -the frame's LEN field (bytes 3-4 of the Rynk header). This strips HID padding so -WASM sees the same clean Rynk byte stream as serial: - -```js -function hidLink(device) { - const N = RYNK_HID_REPORT_SIZE // 32 - let rx = new Uint8Array(0) - let remaining = 0 // bytes left in the in-flight rynk frame; 0 at a boundary - let closed = false - let wake = null - const signal = () => { if (wake) { const w = wake; wake = null; w() } } - - // Trim final-report padding using the frame LEN field. - const onReport = (event) => { - const d = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength) - if (remaining === 0) remaining = RYNK_HEADER + (d[3] | (d[4] << 8)) - const take = Math.min(remaining, d.length) - remaining -= take - rx = concat(rx, d.slice(0, take)) - signal() - } - device.addEventListener('inputreport', onReport) +### 32-byte report framing - // ... ready(), fill(), readFrame() — same pattern as the serial link ... +Each Rynk frame is fragmented into fixed 32-byte reports with report ID 0, +zero-padded to the full report size: - return { - async probeVersion() { /* same as serial, using sendFramed */ }, - async send(f) { await sendFramed(f) }, - async recv() { /* same as serial */ }, - async close() { - closed = true - device.removeEventListener('inputreport', onReport) - try { await device.close() } - catch {} - signal() - }, +```ts +async send(frame: Uint8Array): Promise { + for (let offset = 0; offset < frame.length; offset += 32) { + const report = new Uint8Array(32) + report.set(frame.subarray(offset, offset + 32)) + await this.device.sendReport(0, report) } } ``` -Source: `rynk/rynk-wasm/index.html`, lines 176-242. - -The reassembly logic in `onReport`: +Receiving needs no reassembly bookkeeping: every `inputreport` event's bytes +are pushed as-is. COBS treats the zero padding as frame delimiters, so it +decodes to empty frames and is discarded. The WASM client never sees the +32-byte report structure — it sees the same continuous Rynk byte stream the +bulk endpoint exposes. -1. **When `remaining === 0`**: a new frame is starting. Read the frame's LEN - from the Rynk header (bytes 3-4, little-endian) and set - `remaining = RYNK_HEADER + len` (the total frame size). -2. **Take `min(remaining, d.length)` bytes** from each report. This trims the - zero-padding from the final report of a frame. -3. **Decrement `remaining`** by the number of bytes taken. When it reaches 0, - the next report starts a new frame. +### Method summary -This is why the `JsByteLink` contract says "transport-specific framing must be -hidden below this boundary." The WASM client never sees the 32-byte report -structure — it sees a continuous Rynk byte stream, identical to what Web Serial -exposes. +- **`send(f)`** — splits the frame into 32-byte reports via `sendReport(0, …)`. +- **`recv()`** — `BufferedLink`, fed by the `inputreport` listener. +- **`close()`** — removes the listener, signals EOF, then closes the OS handle + bounded by a 1s race: `close()` queues behind any `sendReport` the device + never accepted, and the session is already over once the listener is gone. -### Method summary +## The Version Probe -- **`probeVersion()`** — same as serial, but sends via `sendFramed()`. -- **`send(f)`** — `sendFramed(f)`, splitting the frame into 32-byte reports. -- **`recv()`** — same as serial: drains `rx`, returns chunks, empty at EOF. -- **`close()`** — removes the `inputreport` listener, closes the HID device, - signals any parked `recv()`. +`probeVersion(link)` sends a COBS-framed `GetVersion` (cmd `0x0001`, seq 1, +empty payload) and scans delimited frames until the reply lands, skipping +interleaved topic pushes. It runs under an **idle watchdog**, not a total cap: +every answer rearms a 5s window, so a slow link that is still talking gets to +keep going — only silence gives up. The watchdog covers the send as well: +`sendReport`/`transferOut` can itself park forever on a granted device that is +not actually speaking rynk, and a deadline that starts after the send never +fires for it. ## Why WebHID not Web Bluetooth A pure browser cannot reach Rynk's custom 128-bit GATT service on an OS-bonded -keyboard. The reasons: - -- **Web Bluetooth cannot attach a bonded keyboard at all.** Web Bluetooth - requires its own pairing flow; it cannot ride an existing OS bond. A keyboard - already bonded to the OS (as BLE keyboards normally are) cannot be accessed - by Web Bluetooth. -- **WebHID reaches the firmware's vendor HID report.** The firmware exposes - `RynkHidService` as a vendor HID report (usage page `0xFF60`). WebHID can - access vendor HID reports via the existing OS HID link — the same path the OS - uses to deliver keyboard input. So there is no pairing prompt; the user only - grants HID access once via the chooser. - -Rynk's custom-GATT BLE transport (`rynk-ble`, native `bluest`) is a separate -native-only path for desktop tools that can perform their own BLE pairing: - -```bash -cargo run -p rynk --example hw_test -- ble -``` +keyboard: -## RynkHidService Requirement +- **Web Bluetooth cannot attach a bonded keyboard at all.** It requires its own + pairing flow; it cannot ride an existing OS bond. +- **WebHID reaches the firmware's vendor HID report** over the same OS link + that delivers keystrokes — no pairing prompt, one chooser grant. -The firmware must expose `RynkHidService` with usage page `0xFF60`. A build -without that HID report will not appear in the WebHID chooser — the -`navigator.hid.requestDevice()` filter will match nothing. +Rynk's custom-GATT BLE transport (`rynk-ble`) is a separate native-only path +for desktop tools that can perform their own BLE pairing. -If the WebHID chooser is empty: +## RynkHidReport Requirement -- Confirm the firmware build includes `RynkHidService` (check the keyboard's - `keyboard.toml` or Rust configuration for the HID report descriptor). -- Confirm the device is connected and bonded at the OS level (it should appear - in the OS Bluetooth settings as a paired keyboard). -- Use a Chromium browser (Chrome or Edge) over `http://localhost`. Firefox and - Safari do not expose `navigator.hid`. +The firmware must expose `RynkHidReport` with usage page `0xFF14`. If the +WebHID chooser is empty: -## Minimal Web Serial Link +- Confirm the firmware build includes the `rynk` feature (the report is gated + on it). +- Confirm the device is connected and bonded at the OS level. +- Use a Chromium browser over `http://localhost` or HTTPS. Firefox and Safari + expose neither `navigator.usb` nor `navigator.hid`. -This is the smallest useful `JsByteLink` shape, without the buffered reader or -version probe. It is sufficient for `connect()` when the page does not need to -probe the version before loading wasm: +## Minimal WebUSB Link -```js -async function openSerialByteLink() { - const port = await navigator.serial.requestPort() - await port.open({ baudRate: 115200 }) +The smallest useful `JsByteLink`, without the shared buffer class — enough for +`connect()` when nothing else needs the stream: - const reader = port.readable.getReader() - const writer = port.writable.getWriter() - let closed = false +```js +async function openUsbByteLink() { + const device = await navigator.usb.requestDevice({ + filters: [{ classCode: 0xFF, subclassCode: 0x52, protocolCode: 0x52 }], + }) + await device.open() + if (device.configuration === null) await device.selectConfiguration(1) + // Resolve interfaceNumber / epIn / epOut from device.configurations by the + // class triple; hardcoded here as 0/1/1 for brevity. + await device.claimInterface(0) return { + label: device.productName ?? 'RMK keyboard', async send(bytes) { - await writer.write(bytes) + const buf = new Uint8Array(bytes.length) + buf.set(bytes) + await device.transferOut(1, buf) }, - async recv() { - if (closed) return new Uint8Array(0) - for (;;) { - const { value, done } = await reader.read() - if (done) { - closed = true - return new Uint8Array(0) - } - if (value && value.length) return value - } + const r = await device.transferIn(1, 4096).catch(() => null) + if (!r?.data) return new Uint8Array(0) // unplugged: EOF + return new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength) }, - async close() { - closed = true - try { await reader.cancel() } - catch {} - try { reader.releaseLock() } - catch {} - try { await writer.close() } - catch {} - try { writer.releaseLock() } - catch {} - try { await port.close() } - catch {} + try { await device.releaseInterface(0) } catch {} + try { await device.close() } catch {} }, } } ``` -Source: `rynk/rynk-wasm/README.md`, lines 116-152. - -Call `requestPort()` inside a user gesture such as a button click. The -`index.html` reference shell uses a more complete buffered version that -supports the pre-load version probe — see the [Web Serial](#web-serial-usb) -section above for that implementation. +Call `requestDevice()` inside a user gesture such as a button click. The full +implementation in `src/rynk/web.ts` adds the shared buffer, the pump-first +rule, and stall recovery. diff --git a/docs/src/rynk-wasm/lifecycle.md b/docs/src/rynk-wasm/lifecycle.md index c952cde..752a799 100644 --- a/docs/src/rynk-wasm/lifecycle.md +++ b/docs/src/rynk-wasm/lifecycle.md @@ -13,7 +13,7 @@ The protocol client (`rynk::Client`) owns the link lifecycle. Its source is The full connect sequence, as implemented by the `index.html` reference shell: -1. **JS opens the browser transport** (Web Serial or WebHID) via a user gesture +1. **JS opens the browser transport** (WebUSB or WebHID) via a user gesture (button click). Both APIs require a user activation. 2. **JS optionally probes the version**: `link.probeVersion()` returns `{ major, minor }` by sending a raw `GetVersion` frame and reading the reply. @@ -225,61 +225,61 @@ JS-side teardown: ```js async function teardown() { // Closing the link EOFs the transport, ending any parked next_event() pump. - if (l) { - await l.close() + if (link) { + await link.close() } - else if (port) { - try { await port.close() } + else if (device) { + try { await device.close() } catch {} } - port = null; device = null; l = null; core = null; client = null; connected = false + device = null; link = null; core = null; client = null; connected = false // ... reset UI ... } ``` -Source: `rynk/rynk-wasm/index.html`, lines 258-267. - ### Auto-reconnect After a disconnect, the page can reconnect to previously granted devices without a new chooser prompt: -- `navigator.serial.getPorts()` — serial ports the user previously granted. +- `navigator.usb.getDevices()` — USB devices the user previously granted, + filtered by the Rynk vendor interface class triple. - `navigator.hid.getDevices()` — HID devices the user previously granted. ```js -async function grantedSerialPort() { - try { return navigator.serial ? (await navigator.serial.getPorts())[0] || null : null } +async function grantedUsbDevice() { + try { + const devs = navigator.usb ? await navigator.usb.getDevices() : [] + return devs.find(hasRynkVendorInterface) || null // class 0xFF, subclass 0x52, protocol 0x52 + } catch { return null } } async function grantedHidDevice() { try { const devs = navigator.hid ? await navigator.hid.getDevices() : [] - return devs.find(d => (d.collections || []).some(c => c.usagePage === 0xFF60)) || devs[0] || null + return devs.find(d => (d.collections || []).some(c => c.usagePage === 0xFF14)) || null } catch { return null } } ``` -Source: `rynk/rynk-wasm/index.html`, lines 370-379. - ### Transport removal events -The browser fires `connect` / `disconnect` events on `navigator.serial` and -`navigator.hid` when a device is plugged or unplugged. The reference shell uses -them to auto-connect when idle and to tear down when the active transport is -removed: +The browser fires `connect` / `disconnect` events on `navigator.usb` and +`navigator.hid` when a device is plugged or unplugged. A page can use them to +auto-connect when idle and to tear down when the active transport is removed: ```js // Reconnect when idle; disconnect on active transport removal. -navigator.serial?.addEventListener?.('connect', () => { if (!connected) autoConnect() }) +navigator.usb?.addEventListener?.('connect', () => { if (!connected) autoConnect() }) navigator.hid?.addEventListener?.('connect', () => { if (!connected) autoConnect() }) function onDrop() { if (connected) teardown().then(() => log('\n— transport disconnected —')) } -navigator.serial?.addEventListener?.('disconnect', onDrop) +navigator.usb?.addEventListener?.('disconnect', onDrop) navigator.hid?.addEventListener?.('disconnect', onDrop) ``` -Source: `rynk/rynk-wasm/index.html`, lines 399-403. +An unplug also rejects the link's pending `transferIn`, so the link signals EOF +on its own — the events are for UI state, not correctness. The pattern: reconnect when idle, disconnect on active transport removal. Do not attempt to reconnect over an active transport that was just removed — tear @@ -291,25 +291,25 @@ reconnect. The reference shell's teardown function, annotated: ```js -let port = null; let device = null; let l = null; let core = null; let client = null; let connected = false +let device = null; let link = null; let core = null; let client = null; let connected = false async function teardown() { // Closing the link EOFs the transport, ending any parked next_event() pump. // Always close the link first so the topic pump's next_event() rejects. - if (l) { - await l.close() + if (link) { + await link.close() } - else if (port) { - try { await port.close() } + else if (device) { + try { await device.close() } catch {} } // Null out all references so the RynkClient (and its WasmTransport) is dropped. // WasmTransport::drop also calls link.close() — safe because close is idempotent. - port = null; device = null; l = null; core = null; client = null; connected = false + device = null; link = null; core = null; client = null; connected = false // Reset UI state. - serialBtn.textContent = 'Connect via Serial (USB)' + usbBtn.textContent = 'Connect via USB (WebUSB)' bleBtn.textContent = 'Connect via BLE (WebHID)' - serialBtn.disabled = false; bleBtn.disabled = false + usbBtn.disabled = false; bleBtn.disabled = false unlockBtn.hidden = true; unlockBtn.disabled = false } ``` diff --git a/docs/src/rynk-wasm/transport.md b/docs/src/rynk-wasm/transport.md index cde52c6..56e0ce5 100644 --- a/docs/src/rynk-wasm/transport.md +++ b/docs/src/rynk-wasm/transport.md @@ -1,7 +1,7 @@ # WasmTransport `WasmTransport` adapts a JS-owned byte link to the `rynk::io::Read` and `Write` -traits. It is the bridge between the browser transport (Web Serial, WebHID, or +traits. It is the bridge between the browser transport (WebUSB, WebHID, or any custom `JsByteLink`) and the protocol client. Source: `rynk/rynk-wasm/src/transport.rs` @@ -174,7 +174,7 @@ impl Drop for WasmTransport { When `WasmTransport` is dropped (the `RynkClient` goes out of scope, or the link is replaced), `Drop` clones the link handle and spawns `link.close()` via -`spawn_local`. This releases the browser transport resources (serial port lock, +`spawn_local`. This releases the browser transport resources (USB interface claim, HID device handle) even if the JS side did not call `close()` explicitly. `close()` should be idempotent — the JS implementation must tolerate being @@ -201,7 +201,7 @@ contract (from `rynk/rynk-wasm/README.md`): link to `connect()`. - **Transport-specific framing must be hidden below this boundary.** For example, WebHID report padding must be stripped so wasm sees the same clean - Rynk byte stream that Web Serial exposes. + Rynk byte stream that the USB bulk endpoint exposes. Reference implementations for both built-in transports are in [JS Byte Link Implementations](./js-byte-link.md). diff --git a/docs/src/rynk/README.md b/docs/src/rynk/README.md index cabab01..19f26b6 100644 --- a/docs/src/rynk/README.md +++ b/docs/src/rynk/README.md @@ -26,7 +26,7 @@ ends of the wire can never disagree about a message's types. ### 3. Transport-Agnostic Byte Link -All transports — USB serial, BLE GATT, browser Web Serial/WebHID — present the +All transports — raw USB, BLE GATT, browser WebUSB/WebHID — present the same `embedded-io-async` `Read + Write` interface to the client. The `RynkDevice` trait abstracts the lifecycle common to every transport (`label` → `open` → `connect`), while discovery remains transport-specific. @@ -47,7 +47,7 @@ rynk/ │ ├── client.rs # RynkClient: #[wasm_bindgen] API + endpoints! macro │ ├── device.rs # WebDevice: RynkDevice for browser │ └── transport.rs # WasmTransport: JsByteLink → Read/Write -├── rynk-serial/ # USB CDC-ACM serial transport (native) +├── rynk-usb/ # Raw-USB vendor bulk transport (native, nusb) ├── rynk-ble/ # BLE GATT transport (native) └── rynk-kle/ # KLE/Vial layout conversion (native + wasm) ``` @@ -66,8 +66,8 @@ rynk/ ```text Host Application │ - ├── rynk-wasm (browser) ─── JsByteLink ─── Web Serial / WebHID - ├── rynk-serial (native) ── SerialTransport ── USB CDC-ACM + ├── rynk-wasm (browser) ─── JsByteLink ─── WebUSB / WebHID + ├── rynk-usb (native) ───── bulk halves ────── USB vendor interface └── rynk-ble (native) ──── BleTransport ──── BLE GATT │ └──► Client ──► Rynk Protocol ──► RMK Firmware diff --git a/docs/src/rynk/architecture.md b/docs/src/rynk/architecture.md index 46e25e1..b7cf4e9 100644 --- a/docs/src/rynk/architecture.md +++ b/docs/src/rynk/architecture.md @@ -2,7 +2,7 @@ Rynk's architecture separates protocol logic, transport I/O, and device lifecycle into distinct layers. This separation is what lets the same client -run over USB serial, BLE GATT, and browser transports. +run over raw USB, BLE GATT, and browser transports. ## Three-Layer Split @@ -87,7 +87,7 @@ own `discover()`. | Transport | Discovery | Link | |-----------|-----------|------| -| `rynk-serial` | `SerialDevice::discover()` — enumerate USB CDC ports by magic marker | `SerialTransport` (tokio-serial) | +| `rynk-usb` | `UsbDevice::discover()` — match the vendor interface class triple | bulk halves (nusb) | | `rynk-ble` | `BleDevice::discover()` — list already-connected devices by service UUID | `BleTransport` (bluest GATT) | | `rynk-wasm` | JS owns discovery (browser chooser) | `WasmTransport` (JsByteLink) | diff --git a/docs/src/rynk/protocol.md b/docs/src/rynk/protocol.md index 24bffea..25ef0d5 100644 --- a/docs/src/rynk/protocol.md +++ b/docs/src/rynk/protocol.md @@ -449,6 +449,6 @@ Source: `rmk-types/src/protocol/rynk/mod.rs:36-45` | `RYNK_SERVICE_UUID` | `0x10900067537f4f0a9b55929e271f61ab` | Rynk GATT service UUID. | | `RYNK_INPUT_CHAR_UUID` | `0x80f9319b0c7443a59738c59d6dda3db9` | Rynk `input_data` characteristic UUID. | | `RYNK_OUTPUT_CHAR_UUID` | `0x198025246f90434693c263dbc509ab55` | Rynk `output_data` characteristic UUID. | -| `RYNK_SERIAL_MAGIC` | `"rynk:"` | Immutable marker prepended to USB serial number so a host can pick RMK keyboards out of all serial ports without probing every device. | +| `RYNK_MAGIC` | `"rynk:"` | Informational marker prepended to the USB serial number (`lsusb`/system reports can identify RMK devices); discovery matches the vendor interface class triple instead. | Source: `rmk-types/src/protocol/rynk/mod.rs:64-80` diff --git a/package.json b/package.json index a0962af..302bd13 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@tsconfig/svelte": "^5.0.4", "@types/node": "^26.1.1", "@types/w3c-web-hid": "^1.0.7", - "@types/w3c-web-serial": "^1.0.8", + "@types/w3c-web-usb": "^1.0.14", "eslint": "^10.7.0", "eslint-plugin-better-tailwindcss": "^4.7.0", "eslint-plugin-svelte": "^3.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32ef6c1..ba603bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,9 @@ importers: '@types/w3c-web-hid': specifier: ^1.0.7 version: 1.0.7 - '@types/w3c-web-serial': - specifier: ^1.0.8 - version: 1.0.8 + '@types/w3c-web-usb': + specifier: ^1.0.14 + version: 1.0.14 eslint: specifier: ^10.7.0 version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) @@ -852,8 +852,8 @@ packages: '@types/w3c-web-hid@1.0.7': resolution: {integrity: sha512-/y97wBH7fYB5vKoDIn11O1ZDMNFLCAVqZ9af0OWDN7VSO2ClErEN2HlGbBuPgHBTUmZOVx1og2ZHX1U2gEJM4Q==} - '@types/w3c-web-serial@1.0.8': - resolution: {integrity: sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==} + '@types/w3c-web-usb@1.0.14': + resolution: {integrity: sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==} '@typescript-eslint/eslint-plugin@8.65.0': resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} @@ -3076,7 +3076,7 @@ snapshots: '@types/w3c-web-hid@1.0.7': {} - '@types/w3c-web-serial@1.0.8': {} + '@types/w3c-web-usb@1.0.14': {} '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 35e65ae..4e6debc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,12 +20,13 @@ tokio = { version = "1.53.1", features = [ "sync", "time" ] } -# The Rynk host transports live upstream: rynk-serial recognises a keyboard by -# its USB serial marker, rynk-ble lists already-connected GATT peers. Neither is -# reimplemented here — discovery rules belong with the protocol. -rynk = { git = "https://github.com/rmk-rs/rmk.git", branch = "main" } -rynk-serial = { git = "https://github.com/rmk-rs/rmk.git", branch = "main" } -rynk-ble = { git = "https://github.com/rmk-rs/rmk.git", branch = "main" } +# The Rynk host transports live upstream: rynk-usb recognises a keyboard by +# the vendor interface class triple, rynk-ble lists already-connected GATT +# peers. Neither is reimplemented here — discovery rules belong with the +# protocol. Branch pins rmk-rs/rmk#1023 until it merges; back to main after. +rynk = { git = "https://github.com/rmk-rs/rmk.git", branch = "feat/rynk-usb" } +rynk-usb = { git = "https://github.com/rmk-rs/rmk.git", branch = "feat/rynk-usb" } +rynk-ble = { git = "https://github.com/rmk-rs/rmk.git", branch = "feat/rynk-usb" } uuid = { version = "1.24.0", features = [ "v4" ] } [lints.clippy] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8c4ce5f..92bedd6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,14 +7,14 @@ use std::collections::HashMap; use tauri::Builder; use tokio::sync::Mutex; -use transport::{Session, ble, serial, tcp}; +use transport::{Session, ble, tcp, usb}; fn main() { Builder::default() .manage(Mutex::new(HashMap::::new())) .invoke_handler(tauri::generate_handler![ - serial::rynk_discover_serial, ble::rynk_discover_ble, tcp::rynk_discover_tcp, - serial::rynk_connect_serial, ble::rynk_connect_ble, tcp::rynk_connect_tcp, + usb::rynk_discover_usb, ble::rynk_discover_ble, tcp::rynk_discover_tcp, + usb::rynk_connect_usb, ble::rynk_connect_ble, tcp::rynk_connect_tcp, transport::rynk_send, transport::rynk_recv, transport::rynk_close, transport::rynk_close_all, ]) diff --git a/src-tauri/src/transport/mod.rs b/src-tauri/src/transport/mod.rs index 129b87b..9f30fec 100644 --- a/src-tauri/src/transport/mod.rs +++ b/src-tauri/src/transport/mod.rs @@ -1,6 +1,6 @@ pub mod ble; -pub mod serial; pub mod tcp; +pub mod usb; use std::collections::HashMap; use std::sync::Arc; diff --git a/src-tauri/src/transport/serial.rs b/src-tauri/src/transport/serial.rs deleted file mode 100644 index 3be3eda..0000000 --- a/src-tauri/src/transport/serial.rs +++ /dev/null @@ -1,40 +0,0 @@ -use rynk::RynkDevice; -use rynk_serial::SerialDevice; -use serde::Serialize; -use tauri::State; - -use super::{Sessions, rynk_pump, spawn_session}; - -#[derive(Serialize)] -pub struct SerialDeviceInfo { - pub path: String, - pub name: Option, -} - -/// `rynk-serial` recognises a keyboard by the marker the firmware prepends to -/// its USB serial number, and deliberately never opens a port to do it — -/// opening a CDC port toggles DTR, which resets some MCUs. -#[tauri::command] -pub async fn rynk_discover_serial() -> Result, String> { - let devices = SerialDevice::discover().map_err(|e| e.to_string())?; - Ok(devices - .into_iter() - .map(|d| SerialDeviceInfo { - path: d.path, - name: d.name, - }) - .collect()) -} - -#[tauri::command] -pub async fn rynk_connect_serial(path: String, sessions: State<'_, Sessions>) -> Result { - // Re-listing is how a path becomes a device: `SerialDevice` is the only - // thing that can open one, and it carries the marker check with it. - let device = SerialDevice::discover() - .map_err(|e| e.to_string())? - .into_iter() - .find(|d| d.path == path) - .ok_or_else(|| format!("no Rynk keyboard at {path}"))?; - let (read, write) = device.open().await.map_err(|e| e.to_string())?; - Ok(spawn_session(sessions, |cmd_rx, data_tx| rynk_pump(read, write, cmd_rx, data_tx)).await) -} diff --git a/src-tauri/src/transport/usb.rs b/src-tauri/src/transport/usb.rs new file mode 100644 index 0000000..ce426c0 --- /dev/null +++ b/src-tauri/src/transport/usb.rs @@ -0,0 +1,48 @@ +use rynk::RynkDevice; +use rynk_usb::UsbDevice; +use serde::Serialize; +use tauri::State; + +use super::{Sessions, rynk_pump, spawn_session}; + +#[derive(Serialize)] +pub struct UsbDeviceInfo { + pub id: String, + pub name: String, +} + +/// The one encoding of a device's identity, shared by discover and connect so +/// the two cannot drift. +fn device_id(device: &UsbDevice) -> String { + format!("{:?}", device.id()) +} + +/// `rynk-usb` recognises a keyboard by the vendor interface class triple the +/// firmware advertises, reading only cached descriptors — no device is opened +/// and there is no DTR to trip. +#[tauri::command] +pub async fn rynk_discover_usb() -> Result, String> { + let devices = UsbDevice::discover().await.map_err(|e| e.to_string())?; + Ok(devices + .into_iter() + .map(|d| UsbDeviceInfo { + id: device_id(&d), + name: d.label(), + }) + .collect()) +} + +#[tauri::command] +pub async fn rynk_connect_usb(id: String, sessions: State<'_, Sessions>) -> Result { + // Re-listing is how an id becomes a device: `DeviceId` is stable across + // enumerations while the keyboard stays plugged in, and `UsbDevice` is the + // only thing that can open one. + let device = UsbDevice::discover() + .await + .map_err(|e| e.to_string())? + .into_iter() + .find(|d| device_id(d) == id) + .ok_or_else(|| format!("no Rynk keyboard {id}"))?; + let (read, write) = device.open().await.map_err(|e| e.to_string())?; + Ok(spawn_session(sessions, |cmd_rx, data_tx| rynk_pump(read, write, cmd_rx, data_tx)).await) +} diff --git a/src/components/LogoCard.svelte b/src/components/LogoCard.svelte index 45a0717..83e6d55 100644 --- a/src/components/LogoCard.svelte +++ b/src/components/LogoCard.svelte @@ -126,7 +126,7 @@ `} onSelect={() => void deviceStore.pick(kind)} > - {kind === 'hid' ? 'Choose a Bluetooth keyboard…' : 'Choose a serial port…'} + {kind === 'hid' ? 'Choose a Bluetooth keyboard…' : 'Choose a USB keyboard…'} {/each} {/if} diff --git a/src/components/TopBar.svelte b/src/components/TopBar.svelte index 0e13fa4..dee2dba 100644 --- a/src/components/TopBar.svelte +++ b/src/components/TopBar.svelte @@ -9,7 +9,7 @@ /// output transport — that answers a different question and lives on the /// Device screen; here, beside the link state, the session is what matters. const LINKS: Record = { - serial: { label: 'USB', icon: 'lucide:usb', tone: 'text-muted-foreground' }, + usb: { label: 'USB', icon: 'lucide:usb', tone: 'text-muted-foreground' }, ble: { label: 'BLE', icon: 'lucide:bluetooth', tone: 'text-info' }, hid: { label: 'HID', icon: 'lucide:usb', tone: 'text-muted-foreground' }, tcp: { label: 'TCP', icon: 'lucide:wifi', tone: 'text-muted-foreground' }, diff --git a/src/lib/device-names.ts b/src/lib/device-names.ts deleted file mode 100644 index 8a33be9..0000000 --- a/src/lib/device-names.ts +++ /dev/null @@ -1,33 +0,0 @@ -const STORAGE_KEY = 'rmk-device-names' - -type Names = Record - -function key(vendorId: number, productId: number): string { - return `${vendorId}:${productId}` -} - -function read(): Names { - try { - return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Names - } - catch { - return {} - } -} - -/// Web Serial reports a port's USB ids and nothing else — no product string, no -/// serial number. The keyboard does report its name over the protocol, and its -/// `vendor_id`/`product_id` are the descriptor's own, so a name learned on one -/// connection labels the same port on the next. -export function rememberDeviceName(vendorId: number, productId: number, name: string): void { - const trimmed = name.trim() - if (!trimmed) return - const names = read() - if (names[key(vendorId, productId)] === trimmed) return - names[key(vendorId, productId)] = trimmed - localStorage.setItem(STORAGE_KEY, JSON.stringify(names)) -} - -export function rememberedDeviceName(vendorId: number, productId: number): string | undefined { - return read()[key(vendorId, productId)] -} diff --git a/src/lib/toast.svelte.ts b/src/lib/toast.svelte.ts index 137aad5..9fd613a 100644 --- a/src/lib/toast.svelte.ts +++ b/src/lib/toast.svelte.ts @@ -6,6 +6,8 @@ interface Toast { title: string /// A second, smaller line — connection errors carry a next step to try. detail?: string + /// Never auto-dismissed; the close button is the only way out. + sticky?: boolean } /// Long enough to read a `describeKeyboardError`; hovering holds the clock. @@ -21,22 +23,19 @@ class ToastStore { #timers = new Map>() #push(type: ToastType, title: string, detail?: string) { - // A retried failure repeats its toast; replace the stale copy rather than - // stack persistent duplicates the user has to close one by one. - if (type === 'error') { - for (const t of [...this.items]) { - if (t.type === 'error' && t.title === title && t.detail === detail) this.dismiss(t.id) - } - } + // A retried failure repeats its toast; sticky errors never expire, so the + // copy already showing says everything the new one would. + if (type === 'error' && this.items.some(t => t.title === title && t.detail === detail)) return const id = ++this.#seq - this.items.push({ id, type, title, detail }) + // Errors stay until dismissed: an expired one leaves a user who looked + // away facing a blank connect screen with no explanation. + this.items.push({ id, type, title, detail, sticky: type === 'error' }) this.#arm(id) } - /// Errors stay until dismissed: an expired one leaves a user who looked away - /// facing a blank connect screen with no explanation. The rest self-expire. #arm(id: number) { - if (this.items.find(t => t.id === id)?.type === 'error') return + const toast = this.items.find(t => t.id === id) + if (!toast || toast.sticky) return this.#timers.set(id, setTimeout(() => this.dismiss(id), DURATION_MS)) } diff --git a/src/pages/Connect.svelte b/src/pages/Connect.svelte index fd76296..b58f44b 100644 --- a/src/pages/Connect.svelte +++ b/src/pages/Connect.svelte @@ -9,23 +9,28 @@ import Segmented from '../components/ui/Segmented.svelte' import { deviceStore, keyboardStore } from '../stores' - type Method = 'serial' | 'ble' + type Method = 'usb' | 'ble' const METHODS = [ - { value: 'serial', label: 'USB', icon: 'lucide:usb' }, + { value: 'usb', label: 'USB', icon: 'lucide:usb' }, { value: 'ble', label: 'Bluetooth', icon: 'lucide:bluetooth' }, ] as const satisfies readonly { value: Method, label: string, icon: string }[] - /// Each tab covers the transports that reach a keyboard that way. The - /// debug-only TCP transport rides with USB rather than becoming unreachable, - /// and WebHID is how the browser reaches an already-bonded Bluetooth board. - const KINDS: Record = { - serial: ['serial', 'tcp'], - ble: ['ble', 'hid'], + /// `Segmented` reconciles by item identity, so hand it one stable array. + const METHOD_ITEMS = [...METHODS] + + /// Which tab lists a transport. The debug-only TCP transport rides with USB + /// rather than becoming unreachable, and WebHID is how the browser reaches + /// an already-bonded Bluetooth board. + const TAB_OF: Record = { + usb: 'usb', + tcp: 'usb', + ble: 'ble', + hid: 'ble', } const KIND_LABELS: Record = { - serial: 'USB', + usb: 'USB', ble: 'Bluetooth', tcp: 'Network', hid: 'Bluetooth', @@ -34,9 +39,9 @@ /// In the browser only a picker can open a device, and it must run inside the /// click. `hid` is the Bluetooth path: Web Bluetooth would demand its own /// pairing and cannot see the bond the OS already holds. - const PICKS: Record = { serial: 'serial', ble: 'hid' } + const PICKS: Record = { usb: 'usb', ble: 'hid' } - let method = $state('serial') + let method = $state('usb') const native = isTauri() @@ -44,14 +49,21 @@ /// hint is about the keyboard; the browser build must explain the OS bond /// it rides on instead. const HINTS: Record = { - serial: 'Plug the keyboard in over USB.', + usb: 'Plug the keyboard in over USB.', ble: native ? 'Turn the keyboard on and bring it in range.' : 'Pair the keyboard with this computer first — the browser can only reach a keyboard the system has already bonded.', } const available = $derived(deviceStore.browserTransports) const canPick = $derived(!native && available.includes(PICKS[method])) - const found = $derived(deviceStore.devices.filter(d => KINDS[method].includes(d.kind))) + /// One pass, both tabs: the stacked grid renders each tab's list on every + /// update, and the pick-button label needs the active one. + const lists = $derived.by(() => { + const groups: Record = { usb: [], ble: [] } + for (const d of deviceStore.devices) groups[TAB_OF[d.kind]].push(d) + return groups + }) + const found = $derived(lists[method]) const connecting = $derived(keyboardStore.connection?.phase === 'connecting') const busy = $derived(connecting || deviceStore.connecting !== null) @@ -89,7 +101,7 @@
({ value: m.value, label: m.label, icon: m.icon }))} + items={METHOD_ITEMS} value={method} fill height={38} @@ -118,7 +130,7 @@ keyboard never sits above a block of reserved space. -->
{#each METHODS as m (m.value)} - {@const list = deviceStore.devices.filter(d => KINDS[m.value].includes(d.kind))} + {@const list = lists[m.value]}
{ await expect(probeVersion(fakeLink([]))).rejects.toThrow('link closed') }) - it('gives up on a device that opens the port and never answers', async () => { - // Without the deadline this parks forever and the store never leaves - // `connecting`. - const silent: JsByteLink = { - label: 'silent', - async send() {}, - recv: () => new Promise(() => {}), - async close() {}, + it('gives up on a device that opens and then never settles anything', async () => { + // Without the watchdog either shape parks forever and the store never + // leaves `connecting`. `stuck` is the WebHID case: sendReport itself can + // park on a granted device that is not actually speaking rynk, so the + // watchdog must cover the send, not just the wait for a reply. + const never = () => new Promise(() => {}) + const links: JsByteLink[] = [ + { label: 'silent', async send() {}, recv: () => never(), async close() {} }, + { label: 'stuck', send: () => never(), recv: () => never(), async close() {} }, + ] + for (const link of links) { + await expect(probeVersion(link, 10)).rejects.toThrow('version probe timed out') } - await expect(probeVersion(silent, 10)).rejects.toThrow('version probe timed out') }) it('keeps waiting as long as the device keeps talking', async () => { @@ -107,19 +110,6 @@ describe('probeVersion', () => { await expect(probeVersion(chatty, 100)).resolves.toEqual({ major: 1, minor: 2 }) }) - it('gives up when the send itself never settles', async () => { - // WebHID sendReport can park forever on a device that matches the vendor - // usage without being an RMK keyboard (a Vial board). The deadline has to - // cover the send, or the connect screen wedges before the recv loop starts. - const stuck: JsByteLink = { - label: 'stuck', - send: () => new Promise(() => {}), - recv: () => new Promise(() => {}), - async close() {}, - } - await expect(probeVersion(stuck, 10)).rejects.toThrow('version probe timed out') - }) - it('classifies both give-up paths as transport faults', async () => { // toKeyboardError() keys off the name; anything else lands in `unknown`. await expect(probeVersion(fakeLink([]))).rejects.toMatchObject({ name: 'TransportError' }) diff --git a/src/rynk/core.ts b/src/rynk/core.ts index 7c7142c..3aa20bb 100644 --- a/src/rynk/core.ts +++ b/src/rynk/core.ts @@ -68,6 +68,22 @@ function transportError(message: string): Error { return e } +/// Rejects with a TransportError once `ms` elapse without `promise` settling. +export async function withDeadline(promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_res, rej) => { + timer = setTimeout(() => rej(transportError(message)), ms) + }), + ]) + } + finally { + clearTimeout(timer) + } +} + /// Frame: cmd=0x0001 LE, seq=1, empty payload; reply payload is [status, major, minor]. export async function probeVersion(link: JsByteLink, timeoutMs = PROBE_TIMEOUT_MS) { let timer: ReturnType | undefined @@ -86,8 +102,7 @@ export async function probeVersion(link: JsByteLink, timeoutMs = PROBE_TIMEOUT_M try { arm() // The watchdog covers the send too: WebHID's sendReport can itself park - // forever on a device that matches the vendor usage but is not an RMK - // keyboard (a Vial board shares usage page 0xFF60). + // forever on a granted device that is not actually speaking rynk. const sent = link.send(cobsEncode(new Uint8Array([GET_VERSION & 0xFF, GET_VERSION >> 8, 1]))) sent.catch(() => {}) await Promise.race([sent, deadline]) @@ -125,6 +140,10 @@ async function loadCore(major: number) { /// `wasm` overrides where the module is fetched from. The browser default /// resolves it next to the JS glue, which Node's fetch cannot do (file: URL). export async function connectClient(link: JsByteLink, wasm?: BufferSource, timeoutMs?: number) { + // Every protocol major we support maps to the same module, so its fetch and + // compile overlap the probe round trip instead of following it. loadCore + // resolves to this same in-flight import; a failed probe leaves it caught. + import('./wasm/rynk_wasm.js').catch(() => {}) const { major, minor } = await probeVersion(link, timeoutMs) const core = await loadCore(major) await core.default(wasm ? { module_or_path: wasm } : undefined) diff --git a/src/rynk/index.ts b/src/rynk/index.ts index d93ddac..b2c81ea 100644 --- a/src/rynk/index.ts +++ b/src/rynk/index.ts @@ -1,11 +1,10 @@ import type { TauriByteLink } from './tauri' -import type { WebByteLink, WebHidLink } from './web' +import type { WebHidLink, WebUsbLink } from './web' import { isTauri } from '@tauri-apps/api/core' -import { rememberedDeviceName } from '../lib/device-names' -import { closeAllSessions, connectBle, connectSerial, connectTcp, discoverBle, discoverSerial, discoverTcp } from './tauri' -import { connectGrantedHid, connectGrantedSerial, grantedHidDevices, grantedSerialPorts, hidLabel, serialLabel } from './web' +import { closeAllSessions, connectBle, connectTcp, connectUsb, discoverBle, discoverTcp, discoverUsb } from './tauri' +import { grantedHidDevices, grantedUsbDevices, hidLabel, openHid, openUsb, usbLabel } from './web' -export type ByteLink = TauriByteLink | WebByteLink | WebHidLink +export type ByteLink = TauriByteLink | WebUsbLink | WebHidLink export interface ConnectedDevice { link: ByteLink @@ -13,67 +12,49 @@ export interface ConnectedDevice { } export interface TransportInfo { - kind: 'serial' | 'ble' | 'tcp' | 'hid' - /// Stable across scans (port path / BLE id / socket address); identifies the - /// live session so a rescan can leave it alone. + kind: 'usb' | 'ble' | 'tcp' | 'hid' + /// Stable across scans (USB device id / BLE id / socket address); identifies + /// the live session so a rescan can leave it alone. id: string label: string connect: () => Promise - /// Web only: the `SerialPort`/`HIDDevice` this entry stands for. Neither API + /// Web only: the `USBDevice`/`HIDDevice` this entry stands for. Neither API /// gives a device an id, so the object itself is the identity — it lets a /// freshly granted handle be matched back to its row in the list. - handle?: SerialPort | HIDDevice + handle?: USBDevice | HIDDevice } /// Devices reachable without a user gesture. Native builds enumerate the /// transports directly; the browser offers what the user has already granted — -/// both APIs list that, so a granted port and a granted HID device appear alike. +/// both APIs list that, so a granted USB keyboard and a granted HID device +/// appear alike. Names come straight off the USB descriptors. export async function discover(): Promise { if (!isTauri()) { - const [ports, devices] = await Promise.all([grantedSerialPorts(), grantedHidDevices()]) - /// Web Serial withholds the product string, so the name comes from the - /// keyboard: what it reported over the protocol on a previous connection, - /// which is the same name the app shows once connected. Only until then - /// does the USB descriptor string of a granted HID sibling stand in — one - /// keyboard exposes both interfaces under the same ids. - const nameOf = (vendorId?: number, productId?: number): string | undefined => { - if (vendorId === undefined || productId === undefined) return undefined - const remembered = rememberedDeviceName(vendorId, productId) - if (remembered) return remembered - return devices.find(d => d.vendorId === vendorId && d.productId === productId)?.productName - } + const [usbs, hids] = await Promise.all([grantedUsbDevices(), grantedHidDevices()]) return [ - // Neither API exposes a device id, so identity is what each does report, - // plus the position that disambiguates two identical keyboards. - ...ports.map((port, i) => { - const { usbVendorId, usbProductId } = port.getInfo() - return { - kind: 'serial' as const, - id: `serial:${usbVendorId}:${usbProductId}:${i}`, - label: nameOf(usbVendorId, usbProductId) ?? serialLabel(port), - connect: () => connectGrantedSerial(port), - handle: port, - } - }), - ...devices.map(device => ({ + ...usbs.map((device, i) => ({ + kind: 'usb' as const, + id: `usb:${device.vendorId}:${device.productId}:${device.serialNumber ?? i}`, + label: usbLabel(device), + connect: () => openUsb(device), + handle: device, + })), + ...hids.map(device => ({ kind: 'hid' as const, id: `hid:${device.vendorId}:${device.productId}:${device.productName}`, - label: nameOf(device.vendorId, device.productId) ?? hidLabel(device), - connect: () => connectGrantedHid(device), + label: hidLabel(device), + connect: () => openHid(device), handle: device, })), ] } - const [serials, bles, tcps] = await Promise.all([ - discoverSerial().catch(() => []), + const [usbs, bles, tcps] = await Promise.all([ + discoverUsb().catch(() => []), discoverBle().catch(() => []), discoverTcp().catch(() => []), ]) return [ - ...serials.map((s) => { - const label = s.name ?? s.path - return { kind: 'serial' as const, id: s.path, label, connect: () => connectSerial(s.path, label) } - }), + ...usbs.map(u => ({ kind: 'usb' as const, id: u.id, label: u.name, connect: () => connectUsb(u.id, u.name) })), ...bles.map((b) => { const label = b.name ?? b.id return { kind: 'ble' as const, id: b.id, label, connect: () => connectBle(b.id, label) } @@ -84,8 +65,8 @@ export async function discover(): Promise { ] } -export { connectClient, keycodeTables } from './core' +export { connectClient, keycodeTables, withDeadline } from './core' export type { JsByteLink } from './core' export { closeAllSessions } export type * from './wasm/rynk_wasm.js' -export { canUseWebHid, canUseWebSerial, requestHidDevice, requestSerialPort } from './web' +export { canUseWebHid, canUseWebUsb, requestHidDevice, requestUsbDevice } from './web' diff --git a/src/rynk/tauri.ts b/src/rynk/tauri.ts index e8d3966..aedcbcd 100644 --- a/src/rynk/tauri.ts +++ b/src/rynk/tauri.ts @@ -18,12 +18,12 @@ export class TauriByteLink { } } -interface SerialDeviceInfo { path: string, name: string | null } +interface UsbDeviceInfo { id: string, name: string } interface BleDeviceInfo { id: string, name: string | null } interface TcpDeviceInfo { addr: string, name: string } -export async function discoverSerial(): Promise { - return invoke('rynk_discover_serial') +export async function discoverUsb(): Promise { + return invoke('rynk_discover_usb') } export async function discoverBle(): Promise { @@ -34,8 +34,8 @@ export async function discoverTcp(): Promise { return invoke('rynk_discover_tcp') } -export async function connectSerial(path: string, label: string): Promise { - const session = await invoke('rynk_connect_serial', { path }) +export async function connectUsb(id: string, label: string): Promise { + const session = await invoke('rynk_connect_usb', { id }) return { link: new TauriByteLink(session, label), label } } diff --git a/src/rynk/web.ts b/src/rynk/web.ts index 69d7078..b493097 100644 --- a/src/rynk/web.ts +++ b/src/rynk/web.ts @@ -1,13 +1,25 @@ import type { ConnectedDevice } from './index' -/// The firmware's `RynkHidService` report, and the vendor-defined usage it sits -/// on. Serial and HID carry the same Rynk byte stream; only the framing differs. +/// The firmware's `RynkHidReport`, and the vendor-defined usage it sits on. +/// USB bulk and HID carry the same Rynk byte stream; only the framing differs. +/// 0xFF14 is Rynk's own page — rmk-rs/rmk#1022 moved it off 0xFF60, which +/// Via/Vial keyboards use, so their boards no longer match the picker filter. const RYNK_HID_REPORT_SIZE = 32 -const RYNK_HID_USAGE_PAGE = 0xFF60 +const RYNK_HID_USAGE_PAGE = 0xFF14 const RYNK_HID_USAGE = 0x61 /// The firmware's collection is the only one on the report; report id 0. const RYNK_HID_REPORT_ID = 0 +/// The RMK vendor bulk interface — the class triple the firmware advertises +/// (`RYNK_USB_INTERFACE_*` in rmk-types), matched instead of any VID/PID. +const RYNK_USB_CLASS = 0xFF +const RYNK_USB_SUBCLASS = 0x52 +const RYNK_USB_PROTOCOL = 0x52 +/// Covers a whole Rynk frame per transfer, and is a multiple of both Full- and +/// High-Speed bulk packet sizes — a partial-packet read length would error the +/// moment the device sends a full packet. +const USB_READ_SIZE = 4096 + function concat(a: Uint8Array, b: Uint8Array): Uint8Array { const c = new Uint8Array(a.length + b.length) c.set(a) @@ -24,7 +36,9 @@ abstract class BufferedLink { protected push(bytes: Uint8Array) { if (!bytes.length) return - this.rx = concat(this.rx, bytes) + // Each chunk arrives in its own transfer/event buffer, so adopting it + // outright is safe — the copy is only needed when a backlog exists. + this.rx = this.rx.length === 0 ? bytes : concat(this.rx, bytes) this.signal() } @@ -48,44 +62,101 @@ abstract class BufferedLink { } } -export class WebByteLink extends BufferedLink { - private reader: ReadableStreamDefaultReader - private writer: WritableStreamDefaultWriter +/// Best-effort teardown that never wedges the caller: a close can queue +/// behind a transfer the device never accepted, and teardown is what the +/// connect screen waits on. The session is already over when this runs — the +/// OS handle gets a moment, then we move on. +async function boundedClose(work: Promise): Promise { + await Promise.race([ + work.catch(() => {}), + new Promise(resolve => setTimeout(resolve, 1_000)), + ]) +} + +interface VendorInterface { + interfaceNumber: number + epIn: number + epOut: number +} + +/// The Rynk vendor interface on a device, or null when it carries none — +/// readable from cached descriptors without opening the device, so this both +/// filters `getDevices()` and locates the endpoints to claim. +function vendorInterface(device: USBDevice): VendorInterface | null { + for (const configuration of device.configurations) { + for (const iface of configuration.interfaces) { + for (const alt of iface.alternates) { + if ( + alt.interfaceClass !== RYNK_USB_CLASS + || alt.interfaceSubclass !== RYNK_USB_SUBCLASS + || alt.interfaceProtocol !== RYNK_USB_PROTOCOL + ) { + continue + } + const epIn = alt.endpoints.find(e => e.direction === 'in' && e.type === 'bulk') + const epOut = alt.endpoints.find(e => e.direction === 'out' && e.type === 'bulk') + if (epIn && epOut) { + return { + interfaceNumber: iface.interfaceNumber, + epIn: epIn.endpointNumber, + epOut: epOut.endpointNumber, + } + } + } + } + } + return null +} - constructor(private port: SerialPort, readonly label: string) { +export class WebUsbLink extends BufferedLink { + /// The pump starts here, before the caller's first send: bulk has no DTR, so + /// the firmware never learns the previous session's host vanished and may be + /// parked on a topic write no one read — only a pending IN transfer drains + /// it. The version probe skips those stale frames. + constructor( + private device: USBDevice, + private iface: VendorInterface, + readonly label: string, + ) { super() - this.reader = port.readable!.getReader() - this.writer = port.writable!.getWriter() void this.pump() } private async pump() { - for (;;) { - const { value, done } = await this.reader.read() - if (done) break - if (value) this.push(value) + try { + while (!this.closed) { + const result = await this.device.transferIn(this.iface.epIn, USB_READ_SIZE) + if (result.status === 'stall') { + await this.device.clearHalt('in', this.iface.epIn) + continue + } + if (result.data) { + this.push(new Uint8Array(result.data.buffer, result.data.byteOffset, result.data.byteLength)) + } + } + } + catch { + // Unplugged, or close() aborted the pending transfer. } this.end() } async send(frame: Uint8Array): Promise { - await this.writer.write(frame) + // A fresh buffer, not the caller's view: transferOut wants plain + // ArrayBuffer backing, which a view over shared memory cannot promise. + const buf = new Uint8Array(frame.length) + buf.set(frame) + await this.device.transferOut(this.iface.epOut, buf) } async close(): Promise { - await this.reader.cancel() - this.reader.releaseLock() - await this.writer.abort() - this.writer.releaseLock() this.end() - // The link owns the port for the session: leaving it open would make the - // next connect to the same port fail with InvalidStateError. - try { - await this.port.close() - } - catch { - // Already closed, or the device was unplugged. - } + // close() also aborts the pump's pending transferIn. + await boundedClose( + this.device.releaseInterface(this.iface.interfaceNumber) + .catch(() => {}) + .then(() => this.device.close()), + ) } } @@ -117,69 +188,75 @@ export class WebHidLink extends BufferedLink { async close(): Promise { this.device.removeEventListener('inputreport', this.listener) this.end() - // close() queues behind any sendReport the device never accepted, so it can - // park as long as the send does. The session is already over once the - // listener is gone — give the OS handle a moment, then move on rather than - // wedge the teardown that the connect screen is waiting on. - await Promise.race([ - this.device.close().catch(() => {}), - new Promise(resolve => setTimeout(resolve, 1_000)), - ]) + await boundedClose(this.device.close()) } } -export function hidLabel(device: HIDDevice): string { - if (device.productName) return device.productName +/// Descriptor product string, or the numeric ids when it carried none. The +/// name comes with the grant — no handshake needed, so even a keyboard that +/// never connects has one. +function deviceLabel( + prefix: string, + d: { productName?: string | null, vendorId: number, productId: number }, +): string { + if (d.productName) return d.productName const id = (n: number) => n.toString(16).padStart(4, '0') - return `HID ${id(device.vendorId)}:${id(device.productId)}` + return `${prefix} ${id(d.vendorId)}:${id(d.productId)}` } -export function canUseWebHid(): boolean { - return typeof navigator !== 'undefined' && 'hid' in navigator +export function hidLabel(device: HIDDevice): string { + return deviceLabel('HID', device) } -export function canUseWebSerial(): boolean { - return typeof navigator !== 'undefined' && 'serial' in navigator +export function usbLabel(device: USBDevice): string { + return deviceLabel('USB', device) } -/// Web Serial reports only the USB ids, never the product string or the `rynk:` -/// serial marker the native transport recognises a keyboard by — so this is as -/// specific as a browser-side label can be. -export function serialLabel(port: SerialPort): string { - const { usbVendorId, usbProductId } = port.getInfo() - if (usbVendorId === undefined || usbProductId === undefined) return 'Serial port' - const id = (n: number) => n.toString(16).padStart(4, '0') - return `USB ${id(usbVendorId)}:${id(usbProductId)}` +function canUse(api: 'usb' | 'hid'): boolean { + return typeof navigator !== 'undefined' && api in navigator +} + +export function canUseWebHid(): boolean { + return canUse('hid') } -async function openSerial(port: SerialPort): Promise { - // `readable` is null until the port is open; a port kept from an earlier - // session in this page is already open and must not be opened twice. - if (!port.readable) await port.open({ baudRate: 115200 }) - const label = serialLabel(port) - return { link: new WebByteLink(port, label), label } +export function canUseWebUsb(): boolean { + return canUse('usb') } -/// Must run inside a click: the browser's own port picker needs the gesture. -/// Returns the handle rather than a session, so the caller can connect through -/// the same list every already-granted device uses. -export async function requestSerialPort(): Promise { - return navigator.serial.requestPort() +export async function openUsb(device: USBDevice): Promise { + const iface = vendorInterface(device) + if (!iface) throw new Error('no Rynk vendor interface on this device') + if (!device.opened) await device.open() + if (device.configuration === null) { + await device.selectConfiguration(device.configurations[0]!.configurationValue) + } + await device.claimInterface(iface.interfaceNumber) + const label = usbLabel(device) + return { link: new WebUsbLink(device, iface, label), label } } -/// Ports the user has already granted this origin. Like the WebHID list these -/// need no gesture, so a granted keyboard shows up in the picker and can be -/// reconnected on launch. -export async function grantedSerialPorts(): Promise { - if (!canUseWebSerial()) return [] - return navigator.serial.getPorts().catch(() => []) +/// Must run inside a click: the browser's own device picker needs the gesture. +export async function requestUsbDevice(): Promise { + return navigator.usb.requestDevice({ + filters: [{ + classCode: RYNK_USB_CLASS, + subclassCode: RYNK_USB_SUBCLASS, + protocolCode: RYNK_USB_PROTOCOL, + }], + }) } -export async function connectGrantedSerial(port: SerialPort): Promise { - return openSerial(port) +/// Keyboards the user has already granted this origin. Like the WebHID list +/// these need no gesture, so a granted keyboard shows up in the app's own list +/// and can be reconnected on launch. +export async function grantedUsbDevices(): Promise { + if (!canUseWebUsb()) return [] + const devices = await navigator.usb.getDevices().catch(() => []) + return devices.filter(d => vendorInterface(d) !== null) } -async function openHid(device: HIDDevice): Promise { +export async function openHid(device: HIDDevice): Promise { if (!device.opened) await device.open() const label = hidLabel(device) return { link: new WebHidLink(device, label), label } @@ -204,7 +281,3 @@ export async function grantedHidDevices(): Promise { d.collections.some(c => c.usagePage === RYNK_HID_USAGE_PAGE && c.usage === RYNK_HID_USAGE), ) } - -export async function connectGrantedHid(device: HIDDevice): Promise { - return openHid(device) -} diff --git a/src/stores/devices.svelte.ts b/src/stores/devices.svelte.ts index db21157..59a6277 100644 --- a/src/stores/devices.svelte.ts +++ b/src/stores/devices.svelte.ts @@ -1,51 +1,31 @@ import type { ConnectedDevice, TransportInfo } from '../rynk' import type { KeyboardError } from './keyboard' import { isTauri } from '@tauri-apps/api/core' -import { rememberDeviceName } from '../lib/device-names' import { toast } from '../lib/toast.svelte' -import { canUseWebHid, canUseWebSerial, closeAllSessions, discover, requestHidDevice, requestSerialPort } from '../rynk' +import { canUseWebHid, canUseWebUsb, closeAllSessions, discover, requestHidDevice, requestUsbDevice, withDeadline } from '../rynk' import { explainKeyboardError, keyboardStore, toKeyboardError } from './keyboard' -function describe(e: unknown): string { - return e instanceof Error ? e.message : String(e) -} - /// Set by an explicit disconnect: a reload must land on the connect screen, /// not silently re-adopt the keyboard the user just left. Any deliberate /// connect clears it. const STAY_DISCONNECTED_KEY = 'rmk-stay-disconnected' -/// Opening can park forever, not just fail: WebHID open/sendReport never -/// settles on some devices that match the usage filter without being RMK -/// keyboards. Generous enough for a slow BLE link, but bounded, so the store -/// always gets its `connecting` state back. Named TransportError so the error -/// mapping reads it as the link's fault, not the app's. +/// Opening can park forever, not just fail — a wedged device, a BLE link that +/// never completes. Generous enough for a slow radio, but bounded, so the +/// store always gets its `connecting` state back. const OPEN_TIMEOUT_MS = 15_000 async function connectWithDeadline(info: TransportInfo): Promise { - let timer: ReturnType | undefined const opening = info.connect() try { - return await Promise.race([ - opening, - new Promise((_res, rej) => { - timer = setTimeout(() => { - const e = new Error('connect timed out') - e.name = 'TransportError' - rej(e) - }, OPEN_TIMEOUT_MS) - }), - ]) + return await withDeadline(opening, OPEN_TIMEOUT_MS, 'connect timed out') } catch (e) { // An open that succeeds after the deadline would hold the port and block // every later attempt; close it whenever it finally lands. - opening.then(d => void d.link.close().catch(() => {})).catch(() => {}) + void opening.then(d => d.link.close()).catch(() => {}) throw e } - finally { - clearTimeout(timer) - } } class DeviceStoreClass { @@ -81,9 +61,11 @@ class DeviceStoreClass { this.#scanning = true try { this.#devices = await discover() + // Marks are per-row; a row that vanished takes its mark with it. + this.#failed = this.#failed.filter(id => this.#devices.some(d => d.id === id)) } catch (e) { - toast.error(describe(e)) + this.fail(toKeyboardError(e)) } finally { this.#scanning = false @@ -128,25 +110,19 @@ class DeviceStoreClass { } this.#connectedId = info.id this.#connectedKind = info.kind - // The keyboard is the only source of its own name in a browser, so learn - // it here — Web Serial will not report it on the next launch. - const identity = keyboardStore.device?.info - if (identity) { - rememberDeviceName(identity.vendor_id, identity.product_id, identity.product_name) - } } catch (e) { this.fail(toKeyboardError(e), info.id) } } - /// `id` is the row to mark failed; picker-level faults have no row. + /// `id` is the row to mark failed; picker- and scan-level faults have no + /// row. `#connectedId`/`#connectedKind` are left alone — their getters gate + /// on the connected phase, which a failure has already ended. private fail(error: KeyboardError, id?: string) { const help = explainKeyboardError(error) toast.error(help.title, help.hint) if (id !== undefined) this.#failed.push(id) - this.#connectedId = null - this.#connectedKind = null } hasFailed(id: string): boolean { @@ -157,14 +133,14 @@ class DeviceStoreClass { /// needs the click that called this to still be the active user gesture. /// `hid` reaches a Bluetooth keyboard the OS already bonded; Web Bluetooth /// would demand a second pairing and cannot see an established one. - async pick(kind: 'serial' | 'hid'): Promise { + async pick(kind: 'usb' | 'hid'): Promise { if (this.#connecting) return localStorage.removeItem(STAY_DISCONNECTED_KEY) this.#connecting = `web-${kind}` try { // The picker only grants access; the grant then joins the same list every // other device comes from, so one device never has two identities. - const handle = kind === 'hid' ? await requestHidDevice() : await requestSerialPort() + const handle = kind === 'hid' ? await requestHidDevice() : await requestUsbDevice() if (keyboardStore.connection) await keyboardStore.resetStore() await this.scan() const listed = this.#devices.find(d => d.handle === handle) @@ -184,10 +160,10 @@ class DeviceStoreClass { } } - get browserTransports(): ('serial' | 'hid')[] { + get browserTransports(): ('usb' | 'hid')[] { if (isTauri()) return [] return [ - ...(canUseWebSerial() ? ['serial' as const] : []), + ...(canUseWebUsb() ? ['usb' as const] : []), ...(canUseWebHid() ? ['hid' as const] : []), ] } diff --git a/src/stores/keyboard/errors.test.ts b/src/stores/keyboard/errors.test.ts index 3cf9a68..248cfc2 100644 --- a/src/stores/keyboard/errors.test.ts +++ b/src/stores/keyboard/errors.test.ts @@ -65,14 +65,18 @@ describe('explainKeyboardError', () => { }) it('reads the open failures every stack reports as a busy device', () => { - // Web Serial and WebHID fail an occupied open with a DOMException name; - // the native serial and BLE stacks say busy/denied in prose. + // WebUSB and WebHID fail an occupied open with a DOMException name; the + // native USB and BLE stacks say busy/denied in prose. const causes = [ - named('NetworkError', 'Failed to open serial port.'), - named('InvalidStateError', 'The port is already open.'), + named('NetworkError', 'Unable to claim interface.'), + named('InvalidStateError', 'The device is already open.'), named('NotAllowedError', 'Failed to open the device.'), new Error('Resource busy'), new Error('Access denied'), + // macOS kIOReturnExclusiveAccess, verbatim from nusb — and as the bare + // string a Tauri command rejection actually delivers it as. + new Error('claim_interface: could not open interface for exclusive access (error 0xe00002c5)'), + 'transport error at claim_interface: could not open interface for exclusive access (error 0xe00002c5)', ] for (const cause of causes) { const help = explainKeyboardError(toKeyboardError(cause)) @@ -84,6 +88,8 @@ describe('explainKeyboardError', () => { it('falls back to the raw message as the hint', () => { const help = explainKeyboardError({ type: 'unknown', cause: new Error('boom') }) expect(help).toEqual({ title: 'Connection failed', hint: 'boom' }) + // A bare-string rejection (Tauri) must keep its message as the hint too. + expect(explainKeyboardError(toKeyboardError('boom'))).toEqual({ title: 'Connection failed', hint: 'boom' }) expect(explainKeyboardError({ type: 'unknown', cause: 42 })).toEqual({ title: 'Connection failed' }) }) diff --git a/src/stores/keyboard/errors.ts b/src/stores/keyboard/errors.ts index 1ff3e0b..78c2cd8 100644 --- a/src/stores/keyboard/errors.ts +++ b/src/stores/keyboard/errors.ts @@ -31,6 +31,9 @@ function isRynkError(s: string): s is RynkError { } export function toKeyboardError(e: unknown): KeyboardError { + // Tauri commands reject with the Rust error as a bare string; wrap it so + // every later match — and the user-facing hint — works on one shape. + if (typeof e === 'string') e = new Error(e) if (!(e instanceof Error)) return { type: 'unknown', cause: e } if (e.message === 'link closed' || TRANSPORT_NAMES.includes(e.name)) { return { type: 'transport', cause: e } @@ -62,16 +65,13 @@ export interface KeyboardErrorHelp { hint?: string } -/// The shapes an occupied or unreachable device fails an open with: Web Serial +/// The shapes an occupied or unreachable device fails an open with: WebUSB /// throws NetworkError/InvalidStateError, WebHID NotAllowedError, and the -/// native serial and BLE stacks report busy/denied in prose. +/// native USB and BLE stacks report busy/denied in prose — macOS says +/// "exclusive access" (kIOReturnExclusiveAccess) when another app holds the +/// vendor interface claim. const OPEN_FAILED_NAMES: readonly string[] = ['NetworkError', 'InvalidStateError', 'NotAllowedError'] -const OPEN_FAILED_RE = /\bbusy\b|in use|access denied|permission/i - -const IN_USE_HELP: KeyboardErrorHelp = { - title: 'Couldn’t open the device', - hint: 'Another app may be using it — close other configurator software, then try again.', -} +const OPEN_FAILED_RE = /\bbusy\b|in use|access denied|permission|exclusive access/i export function explainKeyboardError(e: KeyboardError): KeyboardErrorHelp { return match(e) @@ -95,7 +95,10 @@ export function explainKeyboardError(e: KeyboardError): KeyboardErrorHelp { .with({ type: 'unknown' }, (x): KeyboardErrorHelp => { if (!(x.cause instanceof Error)) return { title: 'Connection failed' } if (OPEN_FAILED_NAMES.includes(x.cause.name) || OPEN_FAILED_RE.test(x.cause.message)) { - return IN_USE_HELP + return { + title: 'Couldn’t open the device', + hint: 'Another app may be using it — close other configurator software, then try again.', + } } return { title: 'Connection failed', hint: x.cause.message || undefined } }) diff --git a/src/stores/keyboard/index.ts b/src/stores/keyboard/index.ts index a0ca380..b3c2733 100644 --- a/src/stores/keyboard/index.ts +++ b/src/stores/keyboard/index.ts @@ -1,5 +1,5 @@ export { describeKeyboardError, explainKeyboardError, toKeyboardError } from './errors' -export type { KeyboardError, KeyboardErrorHelp } from './errors' +export type { KeyboardError } from './errors' export { keyboardStore } from './keyboard.svelte' export type { ConnectionPhase, diff --git a/src/stores/keyboard/keyboard.svelte.ts b/src/stores/keyboard/keyboard.svelte.ts index a5bff2f..5663705 100644 --- a/src/stores/keyboard/keyboard.svelte.ts +++ b/src/stores/keyboard/keyboard.svelte.ts @@ -273,10 +273,10 @@ class KeyboardStoreClass { version, layout, } - // Prefer the name the keyboard reports over the transport label: Web - // Serial only ever offers the constant 'WebSerial', since getInfo() - // exposes no string descriptors. Earlier phases keep the transport label — - // there is no device info before the handshake. + // Prefer the name the keyboard reports over the transport label: the + // label is a descriptor string a transport may have fallen back past + // (bare ids when the descriptor carried no product). Earlier phases keep + // the transport label — there is no device info before the handshake. this.#connection = { phase: 'connected', label: info.product_name.trim() || connected.label } this.#device = newDevice this.#config = newConfig diff --git a/src/stores/keyboard/keyboard.test.ts b/src/stores/keyboard/keyboard.test.ts index 5c11bfd..ced11a5 100644 --- a/src/stores/keyboard/keyboard.test.ts +++ b/src/stores/keyboard/keyboard.test.ts @@ -216,8 +216,8 @@ describe('connect', () => { }) it('labels the session with the reported product name', async () => { - // Web Serial can only offer the constant 'WebSerial' as a transport label, - // so the name the device reports is what the UI has to show. + // A transport label can be a bare-id fallback when the descriptor carried + // no product string, so the name the device reports is what the UI shows. await connected() expect(keyboardStore.connection).toEqual({ phase: 'connected', label: 'Fake60' }) }) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 52c3229..7b2fa04 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,5 @@ /// -/// +/// /// declare module '*.svelte' { diff --git a/tsconfig.json b/tsconfig.json index d1ee4e1..e43b1b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "@/*": ["./src/*"] }, "resolveJsonModule": true, - "types": ["node", "w3c-web-serial", "w3c-web-hid"], + "types": ["node", "w3c-web-usb", "w3c-web-hid"], "allowImportingTsExtensions": true, "strict": true, "noEmit": true, From 4b77f121d4c71a07f2415255eb5686bef3c6abe6 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Thu, 6 Aug 2026 01:39:02 +0800 Subject: [PATCH 3/3] chore(deps): pin rmk to 65df1577 Replace the `feat/rynk-usb` branch pin (#1023 is merged as 7fe0c396e) and the `branch = "main"` pins with one rev, so the desktop app, the fixture firmware and the wasm client always speak the same protocol commit. The CI checkout step existed only to keep two independent resolutions of the moving `main` branch on one commit; with rev pins it is redundant and would override them via RMK_REPO, so drop it. Signed-off-by: Haobo Gu --- .github/workflows/ci.yml | 13 +++---------- .slim/clonedeps.json | 4 ++-- AGENTS.md | 2 +- README.md | 9 +++++---- TESTING.md | 4 ++-- qemu/Cargo.toml | 4 ++-- qemu/run.mjs | 6 +++--- scripts/build-rynk-wasm.py | 9 +++++++-- src-tauri/Cargo.toml | 9 +++++---- 9 files changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db2b46d..43ae31e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,17 +80,10 @@ jobs: sudo apt-get update sudo apt-get install -y qemu-system-misc - # One clone for both artifacts. The firmware and the wasm client speak one - # protocol, so resolving `main` twice can put them on different commits and - # fail the run on wire errors. RMK_REPO is honoured by both build:wasm and - # qemu/run.mjs. - - name: Check out rmk once - run: | - git clone --depth 1 https://github.com/rmk-rs/rmk.git "$RUNNER_TEMP/rmk" - echo "rmk at $(git -C "$RUNNER_TEMP/rmk" rev-parse HEAD)" - echo "RMK_REPO=$RUNNER_TEMP/rmk" >> "$GITHUB_ENV" - - run: pnpm install --frozen-lockfile + # No shared rmk checkout and no RMK_REPO: the wasm client and the firmware + # each resolve the rev pinned in scripts/build-rynk-wasm.py and + # qemu/Cargo.toml, so both land on one protocol commit. - run: pnpm build:wasm - run: pnpm test:qemu diff --git a/.slim/clonedeps.json b/.slim/clonedeps.json index ddeb1a6..928509b 100644 --- a/.slim/clonedeps.json +++ b/.slim/clonedeps.json @@ -1,10 +1,10 @@ { "version": "1.0.0", - "updatedAt": "2026-07-23T00:00:00.000Z", + "updatedAt": "2026-08-06T00:00:00.000Z", "dependencies": [ { "name": "rmk", - "resolvedVersion": "main@ae22ccda", + "resolvedVersion": "main@65df1577", "repoUrl": "https://github.com/rmk-rs/rmk.git", "ref": "main", "path": ".slim/clonedeps/repos/rmk-rs__rmk", diff --git a/AGENTS.md b/AGENTS.md index 5481204..715a568 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,5 +22,5 @@ No verbose/long comments, no ornate dividers (`──`, box-drawing, ASCII art), Read-only dependency source repositories are available under `.slim/clonedeps/repos/` for inspection. Do not edit these clones. -- `.slim/clonedeps/repos/rmk-rs__rmk/` - `rmk-rs/rmk` at `main` (`ae22ccda`); rynk protocol source (full-duplex header+payload protocol, RynkHidService, WASM/TS type generation, command handlers) to help rmk-gui adapt the new communication protocol. +- `.slim/clonedeps/repos/rmk-rs__rmk/` - `rmk-rs/rmk` at `main` (`65df1577`); rynk protocol source (full-duplex header+payload protocol, RynkHidService, WASM/TS type generation, command handlers) to help rmk-gui adapt the new communication protocol. - `.slim/clonedeps/repos/deviceplug__btleplug/` - `deviceplug/btleplug` at `0.12.0` (`ee381ac`); BLE API source for Peripheral/Central traits, notification stream, and write chunking behavior. diff --git a/README.md b/README.md index 3305e8c..a265fe5 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ Make sure you have Rust, NodeJS and Python installed on your system. ``` `src/rynk/wasm/` is a build artifact and is not checked in, so this step is required before anything else runs. It compiles `rynk-wasm` from a sibling - `../rmk` checkout when one exists, otherwise it clones `rmk-rs/rmk`; set - `RMK_REPO` to point somewhere else. + `../rmk` checkout when one exists, otherwise it fetches the rmk revision + pinned in the script; set `RMK_REPO` to point somewhere else. 4. Start the development server: ```bash pnpm dev:web # browser only @@ -77,8 +77,9 @@ one client at a time. The fixture firmware resolves `rmk` the same way step 3 does (`RMK_REPO`, then a sibling `../rmk`), so the firmware and the wasm client stay on one revision. -With neither, cargo falls back to the `main` branch and the two can drift onto -different protocol commits. +With neither, both fall back to the same pinned rmk revision — bump it in +`qemu/Cargo.toml`, `src-tauri/Cargo.toml` and `scripts/build-rynk-wasm.py` +together. `CI=true` matters for linting: the eslint config detects editors and relaxes some rules, so a bare `pnpm lint` is more permissive than CI. diff --git a/TESTING.md b/TESTING.md index e060971..3d5230b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -249,9 +249,9 @@ wasm 预初始化:`mod.default({ module_or_path: await readFile(...) })`。此 新增一个 Linux-only job,与现有 6-OS 构建矩阵并行(不进入关键路径,墙钟时间不变):安装 `qemu-system-misc`、`riscv32imac-unknown-none-elf` target、`wasm-pack`,执行 `pnpm build:wasm` 与 `pnpm build:web` 后 `pnpm test`。 -**须一并处理的依赖一致性问题**:`.cargo/config.toml` 为 gitignore 的本地文件,因此 CI 中固件从 `git+rmk.git?branch=main` 解析,而 `scripts/build-rynk-wasm.py` 会**另外**再 clone 一次 `main`。两次独立拉取同一移动分支,若 `main` 在其间前进,固件与 wasm 客户端将来自不同的协议 commit,测试会以难以定位的线路错误失败。 +**须一并处理的依赖一致性问题**:`.cargo/config.toml` 为 gitignore 的本地文件,因此 CI 中固件由 cargo 从 `git+rmk.git` 解析,而 `scripts/build-rynk-wasm.py` 会**另外**再拉一次源码。两次独立拉取若落在不同 commit(拉移动分支时必然如此),固件与 wasm 客户端将来自不同的协议 commit,测试会以难以定位的线路错误失败。 -处理方式:CI 中只 clone 一次并以 `RMK_REPO` 指向它,同时打印其 `rev-parse HEAD` 便于定位。`qemu/run.mjs` 采用与 `build-rynk-wasm.py` 相同的解析顺序(`RMK_REPO` → 同级 `../rmk`),解析到则通过 `cargo --config` 把 `[patch]` 指向该 checkout。本地开发同样受益:有同级 `../rmk` 时固件与 wasm 客户端自动同源。 +处理方式:`qemu/Cargo.toml` 与 `scripts/build-rynk-wasm.py` 都按同一个 rmk commit 固定(rev pin,而非 `branch = "main"`),两次独立拉取因此必然同源,CI 不需要共享 checkout,也不设 `RMK_REPO`;升级时三处 pin(含 `src-tauri/Cargo.toml`)一起改。`qemu/run.mjs` 采用与 `build-rynk-wasm.py` 相同的解析顺序(`RMK_REPO` → 同级 `../rmk`),解析到则通过 `cargo --config` 把 `[patch]` 指向该 checkout。本地开发同样受益:有同级 `../rmk` 时固件与 wasm 客户端自动同源。 顺带纳入(各一行,成本可忽略):现有 `ci` job 的 ubuntu 分支增加 `pnpm check`(svelte-check 当前为 105 files / 0 errors);`eslint.config.mjs` 的 `'qemu/**'` 忽略项收窄为 `'qemu/src/**'` + `'qemu/target/**'`,使 `qemu/run.mjs` 与新增 harness 脚本纳入 lint。 diff --git a/qemu/Cargo.toml b/qemu/Cargo.toml index 9680acc..0cf3dde 100644 --- a/qemu/Cargo.toml +++ b/qemu/Cargo.toml @@ -20,7 +20,7 @@ bench = false locked = [] [dependencies] -rmk = { git = "https://github.com/rmk-rs/rmk.git", branch = "main", default-features = false, features = [ "rynk" ] } +rmk = { git = "https://github.com/rmk-rs/rmk.git", rev = "65df15775026bad1189139613ee3d338139bec3d", default-features = false, features = [ "rynk" ] } embassy-executor = { version = "0.10", features = [ "platform-riscv32", "executor-thread" @@ -42,4 +42,4 @@ static_cell = "2.0" uart_16550 = "0.6" [build-dependencies] -rmk-config = { git = "https://github.com/rmk-rs/rmk.git", branch = "main" } +rmk-config = { git = "https://github.com/rmk-rs/rmk.git", rev = "65df15775026bad1189139613ee3d338139bec3d" } diff --git a/qemu/run.mjs b/qemu/run.mjs index 3a174ec..100c19a 100644 --- a/qemu/run.mjs +++ b/qemu/run.mjs @@ -14,9 +14,9 @@ const port = process.env.RMK_QEMU_PORT ?? '7965' const RMK_GIT = 'https://github.com/rmk-rs/rmk.git' -/// Same resolution order as scripts/build-rynk-wasm.py. The firmware and the -/// wasm client speak one protocol, so they must come from one rmk revision — -/// letting cargo resolve `branch = "main"` on its own can drift them apart. +/// Same resolution order as scripts/build-rynk-wasm.py: a local checkout builds +/// both the firmware and the wasm client from one working tree. With none, both +/// fall back to the rmk rev pinned in Cargo.toml and the script. function rmkRepo() { const env = process.env.RMK_REPO if (env) return resolve(env) diff --git a/scripts/build-rynk-wasm.py b/scripts/build-rynk-wasm.py index e465974..e0464b4 100644 --- a/scripts/build-rynk-wasm.py +++ b/scripts/build-rynk-wasm.py @@ -2,7 +2,9 @@ import os, shutil, subprocess, sys, tempfile from pathlib import Path -URL, BRANCH = "https://github.com/rmk-rs/rmk.git", "main" +# Keep REV in step with the rmk pins in qemu/Cargo.toml and src-tauri/Cargo.toml — +# firmware and wasm client must come from one protocol commit. +URL, REV = "https://github.com/rmk-rs/rmk.git", "65df15775026bad1189139613ee3d338139bec3d" ROOT = Path(__file__).resolve().parent.parent WASM_OUT = ROOT / "src" / "rynk" / "wasm" @@ -21,7 +23,10 @@ def resolve_repo(): if has_rynk(sibling): return sibling, False work = Path(tempfile.mkdtemp(prefix="rmk-wasm-")) - subprocess.run(["git", "clone", "--depth", "1", "--branch", BRANCH, URL, str(work)], check=True) + # fetch, not clone: `clone --branch` takes a ref, never a sha. + subprocess.run(["git", "init", "-q", str(work)], check=True) + subprocess.run(["git", "-C", str(work), "fetch", "-q", "--depth", "1", URL, REV], check=True) + subprocess.run(["git", "-C", str(work), "checkout", "-q", "FETCH_HEAD"], check=True) return work, True repo, temporary = resolve_repo() diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4e6debc..b6b192a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,10 +23,11 @@ tokio = { version = "1.53.1", features = [ # The Rynk host transports live upstream: rynk-usb recognises a keyboard by # the vendor interface class triple, rynk-ble lists already-connected GATT # peers. Neither is reimplemented here — discovery rules belong with the -# protocol. Branch pins rmk-rs/rmk#1023 until it merges; back to main after. -rynk = { git = "https://github.com/rmk-rs/rmk.git", branch = "feat/rynk-usb" } -rynk-usb = { git = "https://github.com/rmk-rs/rmk.git", branch = "feat/rynk-usb" } -rynk-ble = { git = "https://github.com/rmk-rs/rmk.git", branch = "feat/rynk-usb" } +# protocol. Pinned by rev: the firmware fixture and the wasm client must speak +# the same protocol commit, so all three rmk pins move together. +rynk = { git = "https://github.com/rmk-rs/rmk.git", rev = "65df15775026bad1189139613ee3d338139bec3d" } +rynk-usb = { git = "https://github.com/rmk-rs/rmk.git", rev = "65df15775026bad1189139613ee3d338139bec3d" } +rynk-ble = { git = "https://github.com/rmk-rs/rmk.git", rev = "65df15775026bad1189139613ee3d338139bec3d" } uuid = { version = "1.24.0", features = [ "v4" ] } [lints.clippy]