From 9f4c8880502d89abb55636d7bef84e50bc22c238 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 11 Oct 2026 23:29:33 +0200 Subject: [PATCH 1/6] feat: add Kochi Hummingbird 68 Windows/Mac mode controls --- package-lock.json | 8 ++--- package.json | 2 +- src/app/OverviewPage.tsx | 5 ++-- src/app/cards/KeyboardPanel.tsx | 2 ++ src/app/cards/KochiKeyboardPanel.tsx | 30 +++++++++++++++++++ src/app/workspace-tabs.test.ts | 9 +++++- src/app/workspace-tabs.ts | 5 ++++ src/device-clients.ts | 10 +++++-- src/device/controller.ts | 45 ++++++++++++++-------------- src/device/hid-filters.ts | 4 +-- src/device/keyboard-client.ts | 18 +++++++++++ src/device/keyboard-workspace.ts | 2 ++ src/device/kochi-keyboard.test.ts | 31 +++++++++++++++++++ src/device/kochi-keyboard.ts | 33 ++++++++++++++++++++ src/device/types.ts | 1 + src/device/wuque-keyboard.ts | 3 ++ src/i18n-ar.ts | 1 + src/i18n-de.ts | 1 + src/i18n-es.ts | 1 + src/i18n-fr.ts | 1 + src/i18n-ja.ts | 1 + src/i18n-ko.ts | 1 + src/i18n-pt.ts | 1 + src/i18n-ru.ts | 1 + src/i18n-vi.ts | 1 + src/i18n-zh.ts | 1 + src/i18n.ts | 1 + 27 files changed, 184 insertions(+), 35 deletions(-) create mode 100644 src/app/cards/KochiKeyboardPanel.tsx create mode 100644 src/device/keyboard-client.ts create mode 100644 src/device/kochi-keyboard.test.ts create mode 100644 src/device/kochi-keyboard.ts diff --git a/package-lock.json b/package-lock.json index 1d4e18f6..f792a8b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@openmouse/keyboard-protocol": "1.0.0", + "@openmouse/keyboard-protocol": "git+https://github.com/eichiiiwastaken/keyboard-protocol.git#11a8617ce621346c395ee4cb8aa128748438bc81", "@openmouse/protocol": "0.33.0", "lucide-react": "^1.43.0", "preact": "^10.29.8" @@ -482,9 +482,9 @@ } }, "node_modules/@openmouse/keyboard-protocol": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@openmouse/keyboard-protocol/-/keyboard-protocol-1.0.0.tgz", - "integrity": "sha512-PGvgHBnzZVR53FRkvOFH7+8ouJxOiOcQikKu1WBCOXcCWinoh90xz4F1ePWYqPzzg2iK9NCxD640xyh/wxeIYw==", + "version": "0.1.0", + "resolved": "git+https://github.com/eichiiiwastaken/keyboard-protocol.git#11a8617ce621346c395ee4cb8aa128748438bc81", + "integrity": "sha512-/wioEeR2+WXmBXBd+7lTxzzJkJ53oeVgKTe49PGk9eITDl3h9kJy1fM30C6t5RdvW7dURPtZ7Mjbt3ICTVXLvA==", "license": "AGPL-3.0-or-later", "engines": { "node": ">=20" diff --git a/package.json b/package.json index d6eddba6..f0ffb231 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "preview": "vite preview" }, "dependencies": { - "@openmouse/keyboard-protocol": "1.0.0", + "@openmouse/keyboard-protocol": "git+https://github.com/eichiiiwastaken/keyboard-protocol.git#11a8617ce621346c395ee4cb8aa128748438bc81", "@openmouse/protocol": "0.33.0", "lucide-react": "^1.43.0", "preact": "^10.29.8" diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index e29782c4..648dcc8a 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -1,4 +1,3 @@ -import { WORKSPACE_TAB_ORDER } from "../device/types"; import { ArrowLeft, BarChart3, @@ -66,7 +65,7 @@ import { cardAvailability } from "./cards/availability"; import { TeevolutionProfileCard } from "./cards/teevolution/ProfileCard"; import { deviceImage, deviceImageFilename, isUnknownDevice, showcaseDeviceImageUrls } from "../ui/device-images"; import { BatteryIcon } from "./ui"; -import { availableWorkspaceTab, availableWorkspaceTabs } from "./workspace-tabs"; +import { availableKeyboardWorkspaceTabs, availableWorkspaceTab, availableWorkspaceTabs } from "./workspace-tabs"; function on(tab: WorkspaceTab, tabs: readonly WorkspaceTab[]): boolean { return tabs.includes(tab); @@ -879,7 +878,7 @@ export function OverviewPage({ const { preferences } = snapshot; const locale = preferences.locale; - const tabs = snapshot.keyboard ? WORKSPACE_TAB_ORDER : availableWorkspaceTabs(status !== null, cardAvailability(snapshot), { + const tabs = snapshot.keyboard ? availableKeyboardWorkspaceTabs(snapshot.keyboard.family) : availableWorkspaceTabs(status !== null, cardAvailability(snapshot), { hideLighting: isNoirKsnakeStatus(status), }); const workspaceTab = availableWorkspaceTab(snapshot.workspaceTab, tabs); diff --git a/src/app/cards/KeyboardPanel.tsx b/src/app/cards/KeyboardPanel.tsx index 904fcf18..161d41ed 100644 --- a/src/app/cards/KeyboardPanel.tsx +++ b/src/app/cards/KeyboardPanel.tsx @@ -1,3 +1,4 @@ +import { KochiKeyboardPanel } from "./KochiKeyboardPanel"; import { KeyboardWorkspaceCards } from "./KeyboardWorkspaceCards"; import type { ReactNode } from "react"; import * as control from "../../device/controller"; @@ -22,6 +23,7 @@ function millimetres(value: number): string { export function KeyboardPanel({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const keyboard = snapshot.keyboard; if (!keyboard) return null; + if (keyboard.family === "kochi") return ; const locale = snapshot.preferences.locale; const { identity, polling, trigger } = keyboard; const busy = trigger.busy; diff --git a/src/app/cards/KochiKeyboardPanel.tsx b/src/app/cards/KochiKeyboardPanel.tsx new file mode 100644 index 00000000..403179d5 --- /dev/null +++ b/src/app/cards/KochiKeyboardPanel.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import type { ControlSnapshot } from "../../device/types"; +import * as control from "../../device/controller"; +import { t } from "../../i18n"; +import { Segmented } from "../ui"; + +/** Reuse the keyboard workspace's system-mode control, without Wuque settings. */ +export function KochiKeyboardPanel({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const keyboard = snapshot.keyboard; + if (!keyboard) return null; + const locale = snapshot.preferences.locale; + const mode = keyboard.workspace.systemMode; + const busy = keyboard.trigger.busy; + return ( +
+ {keyboard.trigger.statusText ? {keyboard.trigger.statusText} : null} +
+

{keyboard.name}

{t(locale, "kb.keyboardLabel")}

+ {t(locale, "kb.kochiModeNote")} +
+
+ control.applyKeyboardCommand({ kind: "system", value })} /> +
+ +
+
+ ); +} diff --git a/src/app/workspace-tabs.test.ts b/src/app/workspace-tabs.test.ts index 7f8fb344..f861fa46 100644 --- a/src/app/workspace-tabs.test.ts +++ b/src/app/workspace-tabs.test.ts @@ -2,7 +2,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { CardAvailability } from "./cards/availability.ts"; -import { availableWorkspaceTab, availableWorkspaceTabs } from "./workspace-tabs.ts"; +import { availableKeyboardWorkspaceTabs, availableWorkspaceTab, availableWorkspaceTabs } from "./workspace-tabs.ts"; + +test("HB68 exposes only system mode; existing keyboard tabs remain available", () => { + assert.deepEqual(availableKeyboardWorkspaceTabs("kochi"), ["overview"]); + assert.equal(availableWorkspaceTab("performance", availableKeyboardWorkspaceTabs("kochi")), "overview"); + assert.ok(availableKeyboardWorkspaceTabs("wuque").includes("performance")); + assert.ok(availableKeyboardWorkspaceTabs().includes("lighting")); +}); function availability(enabled: Partial): CardAvailability { return new Proxy(enabled, { get: (target, property) => Reflect.get(target, property) ?? false }) as CardAvailability; diff --git a/src/app/workspace-tabs.ts b/src/app/workspace-tabs.ts index 46ec5f21..7ebdace4 100644 --- a/src/app/workspace-tabs.ts +++ b/src/app/workspace-tabs.ts @@ -27,3 +27,8 @@ export function availableWorkspaceTab( ): WorkspaceTab { return tabs.includes(current) ? current : "overview"; } + +/** The HB68 currently implements only the overview system-mode control. */ +export function availableKeyboardWorkspaceTabs(family?: string): readonly WorkspaceTab[] { + return family === "kochi" ? ["overview"] : WORKSPACE_TAB_ORDER; +} diff --git a/src/device-clients.ts b/src/device-clients.ts index 58134535..14c37ad6 100644 --- a/src/device-clients.ts +++ b/src/device-clients.ts @@ -27,6 +27,8 @@ import { type SupportedClient, } from "@openmouse/protocol/drivers/registry"; import { RAZER_PRODUCTS } from "@openmouse/protocol/razer-devices"; +import { KochiKeyboardClient } from "./device/kochi-keyboard.ts"; +import type { KeyboardClient } from "./device/keyboard-client.ts"; import { WuqueKeyboardClient } from "./device/wuque-keyboard.ts"; export { describeHidDevice } from "./hid-diagnostics.ts"; export { deviceBrand, type PulsarClient, type SupportedClient }; @@ -50,8 +52,11 @@ export function createSupportedClient(device: HIDDevice): SupportedClient | null return registryCreateSupportedClient(device) ?? bridgeRazerClient(device); } -/** A keyboard client, when the device is a Wuque Studio board rather than a mouse. */ -export type KeyboardClient = WuqueKeyboardClient; +export type { KeyboardClient }; + +export function isKeyboardClient(client: SupportedClient | KeyboardClient): client is KeyboardClient { + return client instanceof WuqueKeyboardClient || client instanceof KochiKeyboardClient; +} /** * Map an authorized HID device to a keyboard client. Keyboards are never @@ -59,6 +64,7 @@ export type KeyboardClient = WuqueKeyboardClient; * `MouseStatus` would surface mouse cards for a device that has none. */ export function createKeyboardClient(device: HIDDevice): KeyboardClient | null { + if (KochiKeyboardClient.isSupported(device)) return new KochiKeyboardClient(device); return WuqueKeyboardClient.isSupported(device) ? new WuqueKeyboardClient(device) : null; } diff --git a/src/device/controller.ts b/src/device/controller.ts index 539d293d..f4797635 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -6,6 +6,8 @@ import { pollingRateText } from "../ui/polling-rate"; import { clientSupportScore, createKeyboardClient, + isKeyboardClient, + type KeyboardClient, createSupportedClient, deviceBrand, describeHidDevice, @@ -15,7 +17,6 @@ import { type PulsarClient, type SupportedClient, } from "../device-clients"; -import { WuqueKeyboardClient } from "./wuque-keyboard.ts"; import { closestDpiOption } from "../dpi-presets"; import { lunafurySettingLabel, lunafurySettingPriority } from "./lunafury-labels.ts"; import { lunaFuryLightningPriority, stageLunaFuryProfile } from "./lunafury-profile.ts"; @@ -189,7 +190,6 @@ import { type KeychronNapeLayerKeymap, } from "@openmouse/protocol/keychron"; import { VENDOR_ID } from "@openmouse/protocol/drivers/vendors"; -import { wuqueProductName } from "@openmouse/keyboard-protocol/wuque"; import { BASILISK_V3_PRO_BLUETOOTH, HID_FILTERS } from "./hid-filters"; import { WLMouseHidClient } from "@openmouse/protocol/drivers/wlmouse/hid"; import { WLMouseBeastX4kHidClient } from "@openmouse/protocol/drivers/wlmouse/beast-x-4k-hid"; @@ -265,7 +265,7 @@ const isAnyPreview = previewMode !== null; let active: SupportedClient | null = null; /** The active keyboard, when the selected device is one. Mutually exclusive with `active`. */ -let activeKeyboard: WuqueKeyboardClient | null = null; +let activeKeyboard: KeyboardClient | null = null; let keyboardIdentity: KeyboardIdentityView | null = null; let keyboardPolling: KeyboardPollingView | null = null; let keyboardMatrix: readonly (readonly number[])[] = []; @@ -518,6 +518,7 @@ function deviceArtwork(status: MouseStatus | null): string | null { function buildKeyboardView(): KeyboardView | null { if (!activeKeyboard || !keyboardIdentity) return null; return { + family: activeKeyboard.family, name: keyboardIdentity.name, vendorId: activeKeyboard.device.vendorId, productId: activeKeyboard.device.productId, @@ -2685,8 +2686,8 @@ function sidebarEntries(devices: HIDDevice[]): SidebarDevice[] { index, name: connected && keyboardIdentity ? keyboardIdentity.name - : (device.productName ?? wuqueProductName(device.productId)), - detail: `${st("kb.brand")} · ${connected ? st("ctl.connected") : st("kb.available")}`, + : (device.productName || keyboard.name), + detail: `${keyboard.brand} · ${connected ? st("ctl.connected") : st("kb.available")}`, selected, vendorId: device.vendorId, productId: device.productId, @@ -2808,7 +2809,7 @@ export async function selectAuthorizedDevice(index: number): Promise { .filter((entry): entry is { client: SupportedClient; score: number } => entry.client !== null) .sort((left, right) => right.score - left.score); if (candidates.length === 0) { - const keyboard = group.map(createKeyboardClient).find((client): client is WuqueKeyboardClient => client !== null); + const keyboard = group.map(createKeyboardClient).find((client): client is KeyboardClient => client !== null); if (!keyboard) return; deviceStatusText = st("ctl.switching"); readStatus = st("kb.reading"); @@ -2928,7 +2929,7 @@ export async function applyKeyboardCommand(command: KeyboardCommand): Promise { const client = activeKeyboard; - if (!client || keyboardBusy) return; + if (!client?.setPollingRate || keyboardBusy) return; keyboardBusy = true; keyboardStatusText = st("kb.applying"); emit(); @@ -2982,7 +2983,7 @@ export async function applyKeyboardPolling(hz: number): Promise { */ export async function applyKeyboardTrigger(patch: KeyboardTriggerPatch): Promise { const client = activeKeyboard; - if (!client || keyboardBusy) return; + if (!client?.writeTrigger || keyboardBusy) return; const targets = keyboardSelectedKeys.length > 0 ? keyboardSelectedKeys : keyboardSelected ? [keyboardSelected] : []; @@ -3325,7 +3326,7 @@ async function activateClientNow(client: SupportedClient): Promise { } /** Activate a keyboard: identity, polling and trigger only — no mouse status. */ -async function activateKeyboardClientNow(client: WuqueKeyboardClient): Promise { +async function activateKeyboardClientNow(client: KeyboardClient): Promise { while (refreshInProgress) { await new Promise((resolve) => window.setTimeout(resolve, 25)); } @@ -3345,7 +3346,7 @@ async function activateKeyboardClientNow(client: WuqueKeyboardClient): Promise undefined); keyboardBusy = false; @@ -3368,12 +3369,12 @@ async function activateKeyboardClientNow(client: WuqueKeyboardClient): Promise { +async function loadKeyboardTrigger(client: KeyboardClient): Promise { keyboardBusy = true; - keyboardStatusText = st("kb.readingTrigger"); + keyboardStatusText = st(client.readTrigger ? "kb.readingTrigger" : "kb.reading"); emit(); try { - const trigger = await client.readTrigger(); + const trigger = client.readTrigger ? await client.readTrigger() : { matrix: [], keys: [] }; if (activeKeyboard !== client) return; keyboardMatrix = trigger.matrix; keyboardKeys = trigger.keys; @@ -3397,11 +3398,11 @@ async function loadKeyboardTrigger(client: WuqueKeyboardClient): Promise { } } -function activateClient(target: SupportedClient | WuqueKeyboardClient): Promise { +function activateClient(target: SupportedClient | KeyboardClient): Promise { const run = activationQueue.then(async () => { activationInProgress = true; try { - if (target instanceof WuqueKeyboardClient) await activateKeyboardClientNow(target); + if (isKeyboardClient(target)) await activateKeyboardClientNow(target); else await activateClientNow(target); } finally { activationInProgress = false; @@ -3541,7 +3542,7 @@ function handleHidDisconnect(event: HIDConnectionEvent): void { }); } -async function requestSupportedClient(): Promise { +async function requestSupportedClient(): Promise { if (!navigator.hid) throw new Error(st("ctl.noWebHid")); const devices = await navigator.hid.requestDevice({ filters: HID_FILTERS }); if (devices.length === 0) return null; @@ -3563,7 +3564,7 @@ async function requestSupportedClient(): Promise client !== null); + .find((client): client is KeyboardClient => client !== null); if (keyboard) return keyboard; if (devices.some((device) => device.vendorId === BASILISK_V3_PRO_BLUETOOTH.vendorId @@ -3650,8 +3651,8 @@ export async function connect(): Promise { } deviceStatusText = st("ctl.opening"); readStatus = st("ctl.reading", { - name: client instanceof WuqueKeyboardClient - ? (client.device.productName ?? wuqueProductName(client.device.productId)) + name: isKeyboardClient(client) + ? (client.device.productName || client.name) : statusNameForClient(client), }); emit(); @@ -3702,7 +3703,7 @@ async function reconnectAuthorizedDevice(): Promise { if (clients.length === 0) { const keyboard = listLogicalDevices(devices) .map(createKeyboardClient) - .find((candidate): candidate is WuqueKeyboardClient => candidate !== null); + .find((candidate): candidate is KeyboardClient => candidate !== null); if (keyboard) { deviceStatusText = st("ctl.reconnecting"); readStatus = st("kb.reading"); diff --git a/src/device/hid-filters.ts b/src/device/hid-filters.ts index b97ddd14..1f3e2e60 100644 --- a/src/device/hid-filters.ts +++ b/src/device/hid-filters.ts @@ -1,5 +1,5 @@ import { SUPPORTED_HID_FILTERS } from "@openmouse/protocol/drivers/vendors"; -import { WUQUE_HID_FILTERS } from "@openmouse/keyboard-protocol/drivers"; +import { WUQUE_HID_FILTERS, KOCHI_HID_FILTERS } from "@openmouse/keyboard-protocol/drivers"; /** * The Basilisk V3 Pro in Bluetooth mode. It enumerates under Razer's Bluetooth @@ -10,4 +10,4 @@ import { WUQUE_HID_FILTERS } from "@openmouse/keyboard-protocol/drivers"; export const BASILISK_V3_PRO_BLUETOOTH = { vendorId: 0x068e, productId: 0x00ac }; /** Everything the app asks the browser (or Bridge) for. */ -export const HID_FILTERS: HIDDeviceFilter[] = [...SUPPORTED_HID_FILTERS, ...WUQUE_HID_FILTERS, BASILISK_V3_PRO_BLUETOOTH]; +export const HID_FILTERS: HIDDeviceFilter[] = [...SUPPORTED_HID_FILTERS, ...WUQUE_HID_FILTERS, ...KOCHI_HID_FILTERS, BASILISK_V3_PRO_BLUETOOTH]; diff --git a/src/device/keyboard-client.ts b/src/device/keyboard-client.ts new file mode 100644 index 00000000..a7febbbf --- /dev/null +++ b/src/device/keyboard-client.ts @@ -0,0 +1,18 @@ +import type { KeyboardCommand, KeyboardWorkspace } from "./keyboard-workspace.ts"; +import type { KeyboardIdentityView, KeyboardPollingView, KeyboardTriggerKey, KeyboardTriggerPatch, KeyboardTriggerSettings } from "./types.ts"; + +/** App keyboard contract: drivers expose only the settings they implement. */ +export interface KeyboardClient { + readonly device: HIDDevice; + readonly family: "wuque" | "kochi"; + readonly brand: string; + readonly name: string; + open(): Promise; + close(): Promise; + readIdentity(): Promise; + command(command: KeyboardCommand): Promise>; + readPolling?(): Promise; + setPollingRate?(hz: number): Promise; + readTrigger?(): Promise<{ matrix: readonly (readonly number[])[]; keys: KeyboardTriggerKey[] }>; + writeTrigger?(row: number, col: number, patch: KeyboardTriggerPatch): Promise; +} diff --git a/src/device/keyboard-workspace.ts b/src/device/keyboard-workspace.ts index 64ec7a07..6f17fa35 100644 --- a/src/device/keyboard-workspace.ts +++ b/src/device/keyboard-workspace.ts @@ -3,6 +3,8 @@ import type { WuqueMacroRead, WuqueHidClient } from '@openmouse/keyboard-protoco import type { WuqueAxisReading, WuqueHigherKey, WuqueHigherKeyWrite, WuqueKeyLayoutStyle, WuqueKeyLayoutDefault, WuqueLightingBase, WuqueLightingLed, WuqueLightingPaletteColor, WuqueMacroEvent, WuqueRgb } from '@openmouse/keyboard-protocol/wuque'; export interface KeyboardWorkspace { + /** HB68 mode; independent of the Wuque global-settings structure. */ + systemMode?: "win" | "mac"; global: WuqueGlobalState | null; lighting: WuqueLightingState | null; custom: readonly WuqueLightingLed[]; diff --git a/src/device/kochi-keyboard.test.ts b/src/device/kochi-keyboard.test.ts new file mode 100644 index 00000000..a1af899b --- /dev/null +++ b/src/device/kochi-keyboard.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { KochiKeyboardClient } from "./kochi-keyboard.ts"; +import { createKeyboardClient, createSupportedClient, listLogicalDevices } from "../device-clients.ts"; +import { HID_FILTERS } from "./hid-filters.ts"; + +function device(usagePage = 0xffa0, usage = 1): HIDDevice { + return { vendorId: 0x1ca2, productId: 0x0901, productName: "KOCHI68 Keyboard", opened: false, + collections: [{ usagePage, usage, inputReports: [], outputReports: [], featureReports: [], children: [] }], + } as unknown as HIDDevice; +} + +test("HB68 is discovered as a keyboard and only its command collection is offered", () => { + const config = device(); const boot = device(1, 6); const stream = device(0xffa0, 2); + assert.ok(createKeyboardClient(config) instanceof KochiKeyboardClient); + assert.equal(createSupportedClient(config), null); + assert.equal(createKeyboardClient(boot), null); + assert.equal(createKeyboardClient(stream), null); + assert.deepEqual(listLogicalDevices([boot, stream, config]), [config]); + assert.ok(HID_FILTERS.some(f => f.vendorId === 0x1ca2 && f.productId === 0x0901 && f.usagePage === 0xffa0 && f.usage === 1)); +}); + +test("HB68 rejects unrelated workspace commands and invalid modes before USB access", async () => { + // This fixture has no USB methods: a command reaching hardware would fail. + const client = new KochiKeyboardClient(device()); + await assert.rejects(client.command({ kind: "sleep", value: 30 }), /Windows\/Mac mode only/); + await assert.rejects(client.command({ kind: "save", scope: "all" }), /Windows\/Mac mode only/); + await assert.rejects(client.command({ kind: "system", value: 2 }), RangeError); + assert.equal("readTrigger" in client, false); + assert.equal("setPollingRate" in client, false); +}); diff --git a/src/device/kochi-keyboard.ts b/src/device/kochi-keyboard.ts new file mode 100644 index 00000000..6fadf42d --- /dev/null +++ b/src/device/kochi-keyboard.ts @@ -0,0 +1,33 @@ +import { KochiHidClient } from "@openmouse/keyboard-protocol/drivers/kochi/hid"; +import type { KeyboardCommand, KeyboardWorkspace } from "./keyboard-workspace.ts"; +import type { KeyboardClient } from "./keyboard-client.ts"; +import type { KeyboardIdentityView } from "./types.ts"; + +/** HB68 system-mode controls only. Other commands fail before reaching USB. */ +export class KochiKeyboardClient implements KeyboardClient { + readonly family = "kochi"; + readonly brand = "Kochi"; + readonly name = "Hummingbird 68"; + readonly device: HIDDevice; + private readonly client: KochiHidClient; + + constructor(device: HIDDevice) { this.device = device; this.client = new KochiHidClient(device); } + static isSupported(device: HIDDevice): boolean { return KochiHidClient.isSupported(device); } + open(): Promise { return this.client.open(); } + close(): Promise { return this.client.close(); } + + async readIdentity(): Promise { + const status = await this.client.readStatus(); + return { + brand: status.brand, name: status.name, boardId: null, capabilities: null, + firmwareVersion: status.firmwareVersion, appVersion: null, pcbVersion: null, serial: null, + }; + } + + async command(command: KeyboardCommand): Promise> { + if (command.kind === "refresh") return { systemMode: await this.client.readSystemMode() }; + if (command.kind !== "system") throw new Error("HB68 support currently covers Windows/Mac mode only."); + if (command.value !== 0 && command.value !== 1) throw new RangeError("Expected Windows or Mac mode."); + return { systemMode: await this.client.setSystemMode(command.value === 1 ? "mac" : "win") }; + } +} diff --git a/src/device/types.ts b/src/device/types.ts index 380e7978..0ed49f70 100644 --- a/src/device/types.ts +++ b/src/device/types.ts @@ -233,6 +233,7 @@ export interface KeyboardTriggerView { } export interface KeyboardView { + family?: "wuque" | "kochi"; name: string; vendorId: number; productId: number; diff --git a/src/device/wuque-keyboard.ts b/src/device/wuque-keyboard.ts index 205c415c..d1419271 100644 --- a/src/device/wuque-keyboard.ts +++ b/src/device/wuque-keyboard.ts @@ -122,6 +122,9 @@ function assertApplied( * polling, per-key performance, layout, lighting, and onboard configuration. */ export class WuqueKeyboardClient { + readonly family = "wuque"; + readonly brand = "Wuque Studio"; + get name(): string { return this.device.productName || "Wuque keyboard"; } readonly device: HIDDevice; private readonly client: WuqueHidClient; diff --git a/src/i18n-ar.ts b/src/i18n-ar.ts index d9db7a41..ec9600f7 100644 --- a/src/i18n-ar.ts +++ b/src/i18n-ar.ts @@ -1140,6 +1140,7 @@ export const ar: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "اختر وضع Windows لنظام Windows أو Linux، أو وضع Mac لنظام macOS. يتم تأكيد التغييرات بإعادة قراءة إعدادات لوحة المفاتيح.", "kb.brand": "Wuque Studio", "kb.available": "متاح", "kb.keyboardLabel": "لوحة المفاتيح", diff --git a/src/i18n-de.ts b/src/i18n-de.ts index f985a3d3..36f34b44 100644 --- a/src/i18n-de.ts +++ b/src/i18n-de.ts @@ -1142,6 +1142,7 @@ export const de: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "Windows-Modus für Windows oder Linux, Mac-Modus für macOS. Änderungen werden durch erneutes Auslesen der Tastatur bestätigt.", "kb.brand": "Wuque Studio", "kb.available": "Verfügbar", "kb.keyboardLabel": "TASTATUR", diff --git a/src/i18n-es.ts b/src/i18n-es.ts index 8170c3a5..bb141b56 100644 --- a/src/i18n-es.ts +++ b/src/i18n-es.ts @@ -1142,6 +1142,7 @@ export const es: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "Elige el modo Windows para Windows o Linux, o el modo Mac para macOS. Los cambios se confirman volviendo a leer el teclado.", "kb.brand": "Wuque Studio", "kb.available": "Disponible", "kb.keyboardLabel": "TECLADO", diff --git a/src/i18n-fr.ts b/src/i18n-fr.ts index 68db7012..2770696f 100644 --- a/src/i18n-fr.ts +++ b/src/i18n-fr.ts @@ -1142,6 +1142,7 @@ export const fr: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "Choisissez le mode Windows pour Windows ou Linux, ou le mode Mac pour macOS. Les modifications sont confirmées par une relecture du clavier.", "kb.brand": "Wuque Studio", "kb.available": "Disponible", "kb.keyboardLabel": "CLAVIER", diff --git a/src/i18n-ja.ts b/src/i18n-ja.ts index 4a5e3a55..342128d5 100644 --- a/src/i18n-ja.ts +++ b/src/i18n-ja.ts @@ -1142,6 +1142,7 @@ export const ja: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "WindowsやLinuxではWindowsモード、macOSではMacモードを選択します。変更後にキーボードの設定を再読み込みして確認します。", "kb.brand": "Wuque Studio", "kb.available": "利用可能", "kb.keyboardLabel": "キーボード", diff --git a/src/i18n-ko.ts b/src/i18n-ko.ts index c26950d3..e2e9579d 100644 --- a/src/i18n-ko.ts +++ b/src/i18n-ko.ts @@ -1142,6 +1142,7 @@ export const ko: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "Windows 또는 Linux에서는 Windows 모드, macOS에서는 Mac 모드를 선택하세요. 변경 후 키보드 설정을 다시 읽어 확인합니다.", "kb.brand": "Wuque Studio", "kb.available": "사용 가능", "kb.keyboardLabel": "키보드", diff --git a/src/i18n-pt.ts b/src/i18n-pt.ts index e13a3de6..3c2e54da 100644 --- a/src/i18n-pt.ts +++ b/src/i18n-pt.ts @@ -1142,6 +1142,7 @@ export const pt: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "Escolha o modo Windows para Windows ou Linux, ou o modo Mac para macOS. As alterações são confirmadas relendo o teclado.", "kb.brand": "Wuque Studio", "kb.available": "Disponível", "kb.keyboardLabel": "TECLADO", diff --git a/src/i18n-ru.ts b/src/i18n-ru.ts index 35b4ffd3..0d50eee8 100644 --- a/src/i18n-ru.ts +++ b/src/i18n-ru.ts @@ -1142,6 +1142,7 @@ export const ru: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "Выберите режим Windows для Windows или Linux либо режим Mac для macOS. Изменения подтверждаются повторным чтением настроек клавиатуры.", "kb.brand": "Wuque Studio", "kb.available": "Доступно", "kb.keyboardLabel": "КЛАВИАТУРА", diff --git a/src/i18n-vi.ts b/src/i18n-vi.ts index ddabec1e..a76fa112 100644 --- a/src/i18n-vi.ts +++ b/src/i18n-vi.ts @@ -1148,6 +1148,7 @@ export const vi: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "Chọn chế độ Windows cho Windows hoặc Linux, hoặc chế độ Mac cho macOS. Thay đổi được xác nhận bằng cách đọc lại cài đặt bàn phím.", "kb.brand": "Wuque Studio", "kb.available": "Khả dụng", "kb.keyboardLabel": "BÀN PHÍM", diff --git a/src/i18n-zh.ts b/src/i18n-zh.ts index f59a5a4a..bca5692d 100644 --- a/src/i18n-zh.ts +++ b/src/i18n-zh.ts @@ -1142,6 +1142,7 @@ export const zh: Record = { "m2nex.applied": "{name} applied", "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", + "kb.kochiModeNote": "Windows 或 Linux 请选择 Windows 模式,macOS 请选择 Mac 模式。更改后会重新读取键盘设置进行确认。", "kb.brand": "Wuque Studio", "kb.available": "可用", "kb.keyboardLabel": "键盘", diff --git a/src/i18n.ts b/src/i18n.ts index ec077c4a..dbb99ebe 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1145,6 +1145,7 @@ const en = { "m2nex.appliedDetail": "The mouse is now using this profile.", "m2nex.applyFailed": "Profile apply failed", "m2nex.applyError": "Unable to apply the M2-NEX profile.", + "kb.kochiModeNote": "Choose Windows mode for Windows or Linux, or Mac mode for macOS. Changes are confirmed by reading the keyboard back.", "kb.brand": "Wuque Studio", "kb.available": "Available", "kb.keyboardLabel": "KEYBOARD", From 01621c8ab1c0c8498c6818cefb11494363dd72b2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 11 Oct 2026 23:47:16 +0200 Subject: [PATCH 2/6] chore: use published keyboard-protocol 1.1.0 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f792a8b3..4da764ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@openmouse/keyboard-protocol": "git+https://github.com/eichiiiwastaken/keyboard-protocol.git#11a8617ce621346c395ee4cb8aa128748438bc81", + "@openmouse/keyboard-protocol": "1.1.0", "@openmouse/protocol": "0.33.0", "lucide-react": "^1.43.0", "preact": "^10.29.8" @@ -482,9 +482,9 @@ } }, "node_modules/@openmouse/keyboard-protocol": { - "version": "0.1.0", - "resolved": "git+https://github.com/eichiiiwastaken/keyboard-protocol.git#11a8617ce621346c395ee4cb8aa128748438bc81", - "integrity": "sha512-/wioEeR2+WXmBXBd+7lTxzzJkJ53oeVgKTe49PGk9eITDl3h9kJy1fM30C6t5RdvW7dURPtZ7Mjbt3ICTVXLvA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@openmouse/keyboard-protocol/-/keyboard-protocol-1.1.0.tgz", + "integrity": "sha512-uh+Axg7e8IVg/zk9Au/NVz/+zC7mmajW6FCTPqyVqUKtm/9KMuMFgIghZld3UrYVjBMc1D0iLYlEzlNmaUVUZw==", "license": "AGPL-3.0-or-later", "engines": { "node": ">=20" diff --git a/package.json b/package.json index f0ffb231..b2d7d901 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "preview": "vite preview" }, "dependencies": { - "@openmouse/keyboard-protocol": "git+https://github.com/eichiiiwastaken/keyboard-protocol.git#11a8617ce621346c395ee4cb8aa128748438bc81", + "@openmouse/keyboard-protocol": "1.1.0", "@openmouse/protocol": "0.33.0", "lucide-react": "^1.43.0", "preact": "^10.29.8" From ce9ccf11d6f14c4453ff06911bbeed1498442bc2 Mon Sep 17 00:00:00 2001 From: snekxs Date: Sun, 11 Oct 2026 15:55:27 -0600 Subject: [PATCH 3/6] fix(control): rank keyboards in preferred-device reconnect selection --- src/device/controller.ts | 45 ++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/src/device/controller.ts b/src/device/controller.ts index f4797635..f67f3d58 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -3690,35 +3690,34 @@ async function reconnectAuthorizedDevice(): Promise { const devices = await navigator.hid?.getDevices() ?? []; const preferredKey = storedActiveDeviceKey(); - const clients = listLogicalDevices(devices) - .map((device) => ({ - client: createSupportedClient(device), - preferred: deviceStorageKey(device) === preferredKey, - score: clientSupportScore(device), - })) - .filter((entry): entry is { client: SupportedClient; preferred: boolean; score: number } => entry.client !== null) - .sort((left, right) => Number(right.preferred) - Number(left.preferred) || right.score - left.score) + // Keyboards are not `SupportedClient`s, so rank them alongside mice here: + // a saved keyboard preference must win even when a supported mouse is also + // authorized, and multiple keyboards must respect the saved preference + // too. Mice keep the first slot when nothing is preferred, which was the + // behavior before keyboards joined the reconnect path. + const candidates = listLogicalDevices(devices) + .map((device) => { + const client = createSupportedClient(device) ?? createKeyboardClient(device); + return client === null ? null : { + client, + preferred: deviceStorageKey(device) === preferredKey, + keyboard: isKeyboardClient(client), + score: clientSupportScore(device), + }; + }) + .filter((entry): entry is { client: SupportedClient | KeyboardClient; preferred: boolean; keyboard: boolean; score: number } => entry !== null) + .sort((left, right) => Number(right.preferred) - Number(left.preferred) + || Number(left.keyboard) - Number(right.keyboard) + || right.score - left.score) .map((entry) => entry.client); await refreshSidebar(devices); - if (clients.length === 0) { - const keyboard = listLogicalDevices(devices) - .map(createKeyboardClient) - .find((candidate): candidate is KeyboardClient => candidate !== null); - if (keyboard) { - deviceStatusText = st("ctl.reconnecting"); - readStatus = st("kb.reading"); - emit(); - await activateClient(keyboard); - return; - } - continue; - } + if (candidates.length === 0) continue; - for (const client of clients) { + for (const client of candidates) { if (hasActiveClient()) return; try { deviceStatusText = st("ctl.reconnecting"); - readStatus = st("ctl.readingPrev"); + readStatus = isKeyboardClient(client) ? st("kb.reading") : st("ctl.readingPrev"); emit(); await activateClient(client); return; From 8595f296ccc52317567741edf0b5bb3d622a9040 Mon Sep 17 00:00:00 2001 From: snekxs Date: Sun, 11 Oct 2026 16:02:39 -0600 Subject: [PATCH 4/6] docs: document Kochi keyboard client and reconnect helpers --- src/device-clients.ts | 1 + src/device/controller.ts | 2 ++ src/device/kochi-keyboard.test.ts | 1 + src/device/kochi-keyboard.ts | 6 ++++++ src/device/wuque-keyboard.ts | 1 + 5 files changed, 11 insertions(+) diff --git a/src/device-clients.ts b/src/device-clients.ts index 14c37ad6..63f17b5f 100644 --- a/src/device-clients.ts +++ b/src/device-clients.ts @@ -54,6 +54,7 @@ export function createSupportedClient(device: HIDDevice): SupportedClient | null export type { KeyboardClient }; +/** True when the client drives a keyboard rather than a mouse. */ export function isKeyboardClient(client: SupportedClient | KeyboardClient): client is KeyboardClient { return client instanceof WuqueKeyboardClient || client instanceof KochiKeyboardClient; } diff --git a/src/device/controller.ts b/src/device/controller.ts index f67f3d58..28d1f3bc 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -3398,6 +3398,7 @@ async function loadKeyboardTrigger(client: KeyboardClient): Promise { } } +/** Queue a device activation so only one keyboard/mouse switch runs at a time. */ function activateClient(target: SupportedClient | KeyboardClient): Promise { const run = activationQueue.then(async () => { activationInProgress = true; @@ -3542,6 +3543,7 @@ function handleHidDisconnect(event: HIDConnectionEvent): void { }); } +/** Ask the browser for a device, then pick the best keyboard or mouse client. */ async function requestSupportedClient(): Promise { if (!navigator.hid) throw new Error(st("ctl.noWebHid")); const devices = await navigator.hid.requestDevice({ filters: HID_FILTERS }); diff --git a/src/device/kochi-keyboard.test.ts b/src/device/kochi-keyboard.test.ts index a1af899b..9a77fa84 100644 --- a/src/device/kochi-keyboard.test.ts +++ b/src/device/kochi-keyboard.test.ts @@ -4,6 +4,7 @@ import { KochiKeyboardClient } from "./kochi-keyboard.ts"; import { createKeyboardClient, createSupportedClient, listLogicalDevices } from "../device-clients.ts"; import { HID_FILTERS } from "./hid-filters.ts"; +/** A KOCHI68 fixture carrying a single collection at the given usage. */ function device(usagePage = 0xffa0, usage = 1): HIDDevice { return { vendorId: 0x1ca2, productId: 0x0901, productName: "KOCHI68 Keyboard", opened: false, collections: [{ usagePage, usage, inputReports: [], outputReports: [], featureReports: [], children: [] }], diff --git a/src/device/kochi-keyboard.ts b/src/device/kochi-keyboard.ts index 6fadf42d..79a0614e 100644 --- a/src/device/kochi-keyboard.ts +++ b/src/device/kochi-keyboard.ts @@ -11,11 +11,16 @@ export class KochiKeyboardClient implements KeyboardClient { readonly device: HIDDevice; private readonly client: KochiHidClient; + /** Build the adapter over the shared HB68 HID client. */ constructor(device: HIDDevice) { this.device = device; this.client = new KochiHidClient(device); } + /** True when the device exposes the HB68 command collection. */ static isSupported(device: HIDDevice): boolean { return KochiHidClient.isSupported(device); } + /** Open the HID session; commands require it. */ open(): Promise { return this.client.open(); } + /** Close the HID session and reject any pending exchange. */ close(): Promise { return this.client.close(); } + /** Read the board identity shown in the keyboard header. */ async readIdentity(): Promise { const status = await this.client.readStatus(); return { @@ -24,6 +29,7 @@ export class KochiKeyboardClient implements KeyboardClient { }; } + /** Run a workspace command; only refresh and system mode are supported. */ async command(command: KeyboardCommand): Promise> { if (command.kind === "refresh") return { systemMode: await this.client.readSystemMode() }; if (command.kind !== "system") throw new Error("HB68 support currently covers Windows/Mac mode only."); diff --git a/src/device/wuque-keyboard.ts b/src/device/wuque-keyboard.ts index d1419271..b42a3ce0 100644 --- a/src/device/wuque-keyboard.ts +++ b/src/device/wuque-keyboard.ts @@ -124,6 +124,7 @@ function assertApplied( export class WuqueKeyboardClient { readonly family = "wuque"; readonly brand = "Wuque Studio"; + /** Display name taken from the HID product string. */ get name(): string { return this.device.productName || "Wuque keyboard"; } readonly device: HIDDevice; private readonly client: WuqueHidClient; From c1b35c29bc68fc70fcb5c63a05f69097bf91cccf Mon Sep 17 00:00:00 2001 From: snekxs Date: Sun, 11 Oct 2026 16:08:43 -0600 Subject: [PATCH 5/6] docs: document remaining touched functions for docstring coverage --- src/app/OverviewPage.tsx | 1 + src/app/workspace-tabs.ts | 2 ++ src/device/controller.ts | 5 +++++ src/device/keyboard-workspace.ts | 1 + 4 files changed, 9 insertions(+) diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index 648dcc8a..6c1bffc7 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -864,6 +864,7 @@ function DeviceListView({ snapshot }: { snapshot: ControlSnapshot }): ReactNode ); } +/** The device overview: status, workspace tabs and the active device's cards. */ export function OverviewPage({ snapshot, onOpenCapture, diff --git a/src/app/workspace-tabs.ts b/src/app/workspace-tabs.ts index 7ebdace4..9b234310 100644 --- a/src/app/workspace-tabs.ts +++ b/src/app/workspace-tabs.ts @@ -1,6 +1,7 @@ import { WORKSPACE_TAB_ORDER, type WorkspaceTab } from "../device/types.ts"; import type { CardAvailability } from "./cards/availability.ts"; +/** Tabs a connected mouse may show, narrowed by what its cards support. */ export function availableWorkspaceTabs( connected: boolean, has: CardAvailability, @@ -21,6 +22,7 @@ export function availableWorkspaceTabs( }); } +/** Fall back to the overview when the requested tab is not available. */ export function availableWorkspaceTab( current: WorkspaceTab, tabs: readonly WorkspaceTab[], diff --git a/src/device/controller.ts b/src/device/controller.ts index 28d1f3bc..7cc72e97 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -2666,6 +2666,7 @@ function sidebarEntryForm(client: SupportedClient): "mouse" | "keyboard" { return "mouse"; } +/** One sidebar card per physical device, marking the active one. */ function sidebarEntries(devices: HIDDevice[]): SidebarDevice[] { const groups = logicalDeviceGroups(devices); const entries: SidebarDevice[] = []; @@ -2797,6 +2798,7 @@ async function authorizedDeviceGroups(): Promise { } } +/** Activate the authorized device group the user picked in the device list. */ export async function selectAuthorizedDevice(index: number): Promise { await waitForControllerIdle(); const groups = await authorizedDeviceGroups(); @@ -3516,6 +3518,7 @@ function handleHidConnect(event: HIDConnectionEvent): void { }); } +/** Drop a removed device and switch to a replacement when one is still authorized. */ function handleHidDisconnect(event: HIDConnectionEvent): void { deviceStatuses.delete(event.device); // Multi-collection drivers (cmd + notify) treat either handle as the active mouse. @@ -3638,6 +3641,7 @@ async function requestSupportedClient(): Promise { setConnectionButtons(true, "Connecting…"); deviceStatusText = st("ctl.requesting"); @@ -3675,6 +3679,7 @@ export async function connect(): Promise { } } +/** Restore the active device after a reload, honouring the saved preference. */ async function reconnectAuthorizedDevice(): Promise { if (hasActiveClient() || reconnectInFlight) return; reconnectInFlight = true; diff --git a/src/device/keyboard-workspace.ts b/src/device/keyboard-workspace.ts index 6f17fa35..8ff225cf 100644 --- a/src/device/keyboard-workspace.ts +++ b/src/device/keyboard-workspace.ts @@ -16,6 +16,7 @@ export interface KeyboardWorkspace { higher: WuqueHigherKey | null; adc: WuqueAxisReading | null; } +/** A blank keyboard workspace, before any board data is read. */ export const emptyKeyboardWorkspace = (): KeyboardWorkspace => ({ global: null, lighting: null, custom: [], shell: null, layer: null, styles: [], defaults: [], macro: null, higher: null, adc: null }); export type KeyboardCommand = | { kind: 'refresh' } From d896935721be59fc3f0ca9d069153206a7e5bebe Mon Sep 17 00:00:00 2001 From: snekxs Date: Sun, 11 Oct 2026 16:12:14 -0600 Subject: [PATCH 6/6] docs: cover the last undocumented functions in touched files --- src/app/OverviewPage.tsx | 2 ++ src/app/workspace-tabs.test.ts | 1 + src/device-clients.ts | 1 + src/device/controller.ts | 3 +++ 4 files changed, 7 insertions(+) diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index 6c1bffc7..106c0dac 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -67,6 +67,7 @@ import { deviceImage, deviceImageFilename, isUnknownDevice, showcaseDeviceImageU import { BatteryIcon } from "./ui"; import { availableKeyboardWorkspaceTabs, availableWorkspaceTab, availableWorkspaceTabs } from "./workspace-tabs"; +/** True when the workspace tab is present in the list. */ function on(tab: WorkspaceTab, tabs: readonly WorkspaceTab[]): boolean { return tabs.includes(tab); } @@ -759,6 +760,7 @@ export function Workspace({ ); } +/** The authorized-device list shown when no device is active. */ function DeviceListView({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const devices = snapshot.devices; const locale = snapshot.preferences.locale; diff --git a/src/app/workspace-tabs.test.ts b/src/app/workspace-tabs.test.ts index f861fa46..06942797 100644 --- a/src/app/workspace-tabs.test.ts +++ b/src/app/workspace-tabs.test.ts @@ -11,6 +11,7 @@ test("HB68 exposes only system mode; existing keyboard tabs remain available", ( assert.ok(availableKeyboardWorkspaceTabs().includes("lighting")); }); +/** A CardAvailability fixture where every unset field reads as false. */ function availability(enabled: Partial): CardAvailability { return new Proxy(enabled, { get: (target, property) => Reflect.get(target, property) ?? false }) as CardAvailability; } diff --git a/src/device-clients.ts b/src/device-clients.ts index 63f17b5f..62fe78f8 100644 --- a/src/device-clients.ts +++ b/src/device-clients.ts @@ -48,6 +48,7 @@ function bridgeRazerClient(device: HIDDevice): SupportedClient | null { } +/** The mouse client for a device, including Bridge-exposed Razer boards. */ export function createSupportedClient(device: HIDDevice): SupportedClient | null { return registryCreateSupportedClient(device) ?? bridgeRazerClient(device); } diff --git a/src/device/controller.ts b/src/device/controller.ts index 7cc72e97..2bcf9ad5 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -2659,6 +2659,7 @@ function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void { emit(); } +/** Whether a client appears as a keyboard or a mouse in the sidebar. */ function sidebarEntryForm(client: SupportedClient): "mouse" | "keyboard" { if (client instanceof KeychronNapeHidClient || client instanceof WootingHidClient @@ -3239,6 +3240,7 @@ async function readReceiverRegisterF1( }; } +/** Activate a mouse client and remember it as the preferred device. */ async function activateClientNow(client: SupportedClient): Promise { while (refreshInProgress) { await new Promise((resolve) => window.setTimeout(resolve, 25)); @@ -3465,6 +3467,7 @@ function showDisconnectedState(): void { setConnectionButtons(false, "Add device"); } +/** Refresh the sidebar when an already-active device reconnects. */ function handleHidConnect(event: HIDConnectionEvent): void { if (activeDevice === event.device) { void refreshSidebar();