diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db2b46db..43ae31e3 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 ddeb1a6a..928509b2 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 54812049..715a5681 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 d778b121..a265fe57 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 @@ -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 e0609710..3d5230bf 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/WEBUSB.md b/WEBUSB.md new file mode 100644 index 00000000..67dbc7cc --- /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 1983f868..d3837623 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 870ce5f4..56b07a7c 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 e89becb1..63c15708 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 7c508aab..f5bae191 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 857b117f..00000000 --- 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 00000000..200fc487 --- /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 776cffba..701cb0b0 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 ae407117..dc0693ce 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 69bab0d2..c0521ad7 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 c952cde9..752a799c 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 cde52c65..56e0ce52 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 cabab011..19f26b61 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 46e25e11..b7cf4e9e 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 24bffea0..25ef0d55 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 a0962afa..302bd137 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 32ef6c12..ba603bfa 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/qemu/Cargo.toml b/qemu/Cargo.toml index 9680acc9..0cf3dde3 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 3a174ec5..100c19af 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 e4659749..e0464b45 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 35e65aeb..b6b192ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,12 +20,14 @@ 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. 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] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8c4ce5f1..92bedd67 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 129b87b0..9f30fec0 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 3be3eda5..00000000 --- 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 00000000..ce426c0e --- /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 45a07178..83e6d554 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 0e13fa47..dee2dbae 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/components/ui/Pill.svelte b/src/components/ui/Pill.svelte index 5d4e86c0..3b30c5e4 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 0e85968c..8f200ac0 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 2f93993b..b9141a0e 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 +235,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 e11add27..e9eea331 100644 --- a/src/rynk/core.test.ts +++ b/src/rynk/core.test.ts @@ -76,16 +76,38 @@ describe('probeVersion', () => { 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', + 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') + } + }) + + 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(() => {}), + recv: () => new Promise((resolve) => { + calls += 1 + const frame = calls <= 10 ? topic : versionReply(0x0001, 1, 2) + setTimeout(resolve, 20, frame) + }), async close() {}, } - await expect(probeVersion(silent, 10)).rejects.toThrow('version probe timed out') + await expect(probeVersion(chatty, 100)).resolves.toEqual({ major: 1, minor: 2 }) }) it('classifies both give-up paths as transport faults', async () => { diff --git a/src/rynk/core.ts b/src/rynk/core.ts index e6ebbc61..3aa20bb0 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 { @@ -66,23 +68,53 @@ 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) { - 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 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]) 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 } @@ -108,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 d93ddac8..b2c81ea1 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 e8d39663..aedcbcdf 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 ec358ff3..b4930972 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,67 +188,75 @@ 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. - } + 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 } @@ -202,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 c2ef11db..59a6277c 100644 --- a/src/stores/devices.svelte.ts +++ b/src/stores/devices.svelte.ts @@ -1,32 +1,49 @@ -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 { canUseWebHid, canUseWebSerial, closeAllSessions, discover, requestHidDevice, requestSerialPort } from '../rynk' -import { describeKeyboardError, keyboardStore } from './keyboard' - -function describe(e: unknown): string { - return e instanceof Error ? e.message : String(e) -} +import { toast } from '../lib/toast.svelte' +import { canUseWebHid, canUseWebUsb, closeAllSessions, discover, requestHidDevice, requestUsbDevice, withDeadline } from '../rynk' +import { explainKeyboardError, keyboardStore, toKeyboardError } from './keyboard' /// 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 — 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 { + const opening = info.connect() + try { + 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. + void opening.then(d => d.link.close()).catch(() => {}) + throw e + } +} + 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. @@ -44,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) { - this.#error = describe(e) + this.fail(toKeyboardError(e)) } finally { this.#scanning = false @@ -82,44 +101,46 @@ 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 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.#error = describe(e) - this.#connectedId = null - this.#connectedKind = null + this.fail(toKeyboardError(e), info.id) } } + /// `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) + } + + 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 /// 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}` - 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. - 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) @@ -130,17 +151,19 @@ 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 } } - get browserTransports(): ('serial' | 'hid')[] { + get browserTransports(): ('usb' | 'hid')[] { if (isTauri()) return [] return [ - ...(canUseWebSerial() ? ['serial' as const] : []), + ...(canUseWebUsb() ? ['usb' as const] : []), ...(canUseWebHid() ? ['hid' as const] : []), ] } @@ -149,7 +172,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 3e6f904c..248cfc2b 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,53 @@ 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', () => { + // 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', '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)) + 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' }) + // 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' }) + }) + + 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 d985d7dc..78c2cd82 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 } @@ -54,3 +57,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: WebUSB +/// throws NetworkError/InvalidStateError, WebHID NotAllowedError, and the +/// 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|exclusive access/i + +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 { + 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 } + }) + .exhaustive() +} diff --git a/src/stores/keyboard/index.ts b/src/stores/keyboard/index.ts index 24882123..b3c27332 100644 --- a/src/stores/keyboard/index.ts +++ b/src/stores/keyboard/index.ts @@ -1,4 +1,4 @@ -export { describeKeyboardError, toKeyboardError } from './errors' +export { describeKeyboardError, explainKeyboardError, toKeyboardError } from './errors' export type { KeyboardError } from './errors' export { keyboardStore } from './keyboard.svelte' export type { diff --git a/src/stores/keyboard/keyboard.svelte.ts b/src/stores/keyboard/keyboard.svelte.ts index 2954d55c..56637059 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) } @@ -267,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 5c11bfd0..ced11a5c 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 52c32292..7b2fa041 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 d1ee4e12..e43b1b79 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,