From b54e57bb24e545cc1691a16e9d57593a9daad975 Mon Sep 17 00:00:00 2001 From: nyedle <232406712+nyedle@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:19:32 +0300 Subject: [PATCH 1/3] wlmouse --- src/devices/registry.test.ts | 2 +- src/devices/wlmouse/hid.test.ts | 55 +++++++++++++++++++++++++++++++++ src/devices/wlmouse/hid.ts | 19 ++++++------ 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 src/devices/wlmouse/hid.test.ts diff --git a/src/devices/registry.test.ts b/src/devices/registry.test.ts index 42bb41ab..bfe021c9 100644 --- a/src/devices/registry.test.ts +++ b/src/devices/registry.test.ts @@ -150,7 +150,7 @@ test("every product id offered in the picker has a driver", () => { ); }); -const WITHOUT_TESTS = new Set(["lamzu", "pulsar", "teevolution", "wlmouse"]); +const WITHOUT_TESTS = new Set(["lamzu", "pulsar", "teevolution"]); function deviceDirectories(): string[] { return readdirSync(DEVICES_DIR, { withFileTypes: true }) diff --git a/src/devices/wlmouse/hid.test.ts b/src/devices/wlmouse/hid.test.ts new file mode 100644 index 00000000..636afd2d --- /dev/null +++ b/src/devices/wlmouse/hid.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { WLMouseHidClient } from "./hid.ts"; +import { VENDOR_ID } from "../vendors.ts"; + +const globals = globalThis as { window?: { setTimeout: typeof setTimeout } }; +globals.window ??= { setTimeout }; + +function fakeDevice(offset: number, sleepingReplies = 0) { + const sent: Uint8Array[] = []; + const device = { + vendorId: VENDOR_ID.wlmouse, + productId: 0xa863, + productName: "Huan", + opened: true, + collections: [], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_id: number, data: Uint8Array) => void sent.push(new Uint8Array(data)), + receiveFeatureReport: async () => { + const request = sent[sent.length - 1]; + const reply = new Uint8Array(64); + if (sent.length <= sleepingReplies) { + reply[offset] = 0xa0; + return new DataView(reply.buffer); + } + const payload = request[4] === 0x01 && request[5] === 0x81 + ? [0x01, 0x01, 0x06, 0x40, 0x06, 0x40] + : [0x01, 0x01]; + reply[offset] = 0xa1; + reply[3 + offset] = payload.length; + reply[4 + offset] = request[4]; + reply[5 + offset] = request[5]; + reply.set(payload, 6 + offset); + return new DataView(reply.buffer); + }, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + return { device: device as unknown as HIDDevice, sent }; +} + +for (const offset of [0, 1]) { + test(`a reply shifted by ${offset} byte(s) is decoded`, async () => { + const status = await new WLMouseHidClient(fakeDevice(offset).device).readStatus(); + assert.equal(status.dpi, 1600); + }); +} + +test("a sleeping mouse gets the command re-sent", async () => { + const { device, sent } = fakeDevice(1, 2); + await new WLMouseHidClient(device).readStatus(); + assert.ok(sent.length > 3, `expected re-sends while asleep, saw ${sent.length}`); +}); diff --git a/src/devices/wlmouse/hid.ts b/src/devices/wlmouse/hid.ts index fcce1423..e2c1d515 100644 --- a/src/devices/wlmouse/hid.ts +++ b/src/devices/wlmouse/hid.ts @@ -10,6 +10,7 @@ const RESPONSE_ATTEMPTS = 12; const RESPONSE_DELAY_MS = 30; const WAKE_DELAY_MS = 300; const QUICK_ATTEMPTS = 3; +const FRAME_OFFSETS = [0, 1] as const; const SLEEP_DISABLED = 0xffff; const SLEEP_DISABLED_MIN = 0xff00; @@ -412,22 +413,22 @@ export class WLMouseHidClient { packet[4] = spec.page; packet[5] = spec.command; packet.set(spec.args, HEADER_LENGTH); - await this.device.sendFeatureReport(REPORT_ID, packet); const attempts = spec.attempts ?? RESPONSE_ATTEMPTS; for (let attempt = 0; attempt < attempts; attempt += 1) { + await this.device.sendFeatureReport(REPORT_ID, packet); + await this.delay(RESPONSE_DELAY_MS); const reply = this.copyDataView(await this.device.receiveFeatureReport(REPORT_ID)); - if (reply[0] === STATUS.unsupported) throw new Error(this.describe(spec, "is not supported by this mouse")); - if (reply[0] === STATUS.ok && reply[4] === spec.page && reply[5] === spec.command) { - const length = Math.min(reply[3], PACKET_LENGTH - HEADER_LENGTH); - return reply.slice(HEADER_LENGTH, HEADER_LENGTH + length); - } - if (reply[0] !== STATUS.pending && reply[0] !== STATUS.ok) { - throw new Error(this.describe(spec, `returned an unexpected status 0x${reply[0].toString(16)}`)); + for (const offset of FRAME_OFFSETS) { + if (reply[4 + offset] !== spec.page || reply[5 + offset] !== spec.command) continue; + if (reply[offset] === STATUS.unsupported) throw new Error(this.describe(spec, "is not supported by this mouse")); + if (reply[offset] !== STATUS.ok) continue; + const start = HEADER_LENGTH + offset; + return reply.slice(start, start + Math.min(reply[3 + offset], PACKET_LENGTH - start)); } await this.delay(attempt < QUICK_ATTEMPTS ? RESPONSE_DELAY_MS : WAKE_DELAY_MS); } - throw new Error(this.describe(spec, "got no answer — the mouse may be asleep or out of range")); + throw new Error(this.describe(spec, "got no answer, the mouse may be asleep or out of range")); } private describe(spec: WLMouseRequest, problem: string): string { From cefb873f62d761118ef6ed241400572406d767c1 Mon Sep 17 00:00:00 2001 From: nyedle <232406712+nyedle@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:02:10 +0300 Subject: [PATCH 2/3] QoL & Offline --- public/sw.js | 55 +++++++++++++++++++++++++++++++ src/control-events.ts | 4 +++ src/control-template.ts | 1 + src/control.ts | 33 ++++++++++++++++++- src/interface-preferences.test.ts | 2 ++ src/interface-preferences.ts | 3 ++ src/ui/pending-bar.ts | 20 +++++++++++ 7 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 public/sw.js diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..6be9a45b --- /dev/null +++ b/public/sw.js @@ -0,0 +1,55 @@ +const CACHE = "openmouse"; +const CACHEABLE_HOSTS = ["fonts.googleapis.com", "fonts.gstatic.com"]; + +function isImmutable(url) { + if (url.origin === self.location.origin) { + return url.pathname.startsWith("/assets/") || url.pathname === "/favicon.ico"; + } + return CACHEABLE_HOSTS.includes(url.hostname); +} + +self.addEventListener("install", (event) => { + event.waitUntil((async () => { + const cache = await caches.open(CACHE); + await cache.addAll(["/index.html", "/favicon.ico"]).catch(() => undefined); + await self.skipWaiting(); + })()); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil((async () => { + const names = await caches.keys(); + await Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name))); + await self.clients.claim(); + })()); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + + if (request.mode === "navigate") { + event.respondWith((async () => { + try { + const response = await fetch(request); + (await caches.open(CACHE)).put("/index.html", response.clone()); + return response; + } catch { + return await caches.match("/index.html") ?? Response.error(); + } + })()); + return; + } + + if (!isImmutable(new URL(request.url))) return; + + event.respondWith((async () => { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok || response.type === "opaque") { + (await caches.open(CACHE)).put(request, response.clone()); + } + return response; + })()); +}); diff --git a/src/control-events.ts b/src/control-events.ts index 658a1825..436c5089 100644 --- a/src/control-events.ts +++ b/src/control-events.ts @@ -23,6 +23,7 @@ export interface ControlEventHandlers { setInterfaceTheme(value: string): void; setReducedMotion(enabled: boolean): void; setExpandSections(enabled: boolean): void; + setInstantFlash(enabled: boolean): void; setShowExperimental(enabled: boolean): void; toggleSidebar(): void; resetInterfacePreferences(): void; @@ -110,6 +111,9 @@ export function bindControlEvents(handlers: ControlEventHandlers): void { document.querySelector("#interface-reduced-motion")?.addEventListener("change", (event) => { handlers.setReducedMotion((event.target as HTMLInputElement).checked); }); + document.querySelector("#interface-instant-flash")?.addEventListener("change", (event) => { + handlers.setInstantFlash((event.target as HTMLInputElement).checked); + }); document.querySelector("#interface-expand-sections")?.addEventListener("change", (event) => { handlers.setExpandSections((event.target as HTMLInputElement).checked); }); diff --git a/src/control-template.ts b/src/control-template.ts index 417bfb8b..90287236 100644 --- a/src/control-template.ts +++ b/src/control-template.ts @@ -112,6 +112,7 @@ export function controlTemplate(buildLabel: string): string {
LAYOUT

Interface density

Choose tighter controls or add more breathing room throughout the panel.

APPEARANCE

Accent theme

Customize active controls, status lights, switches, and focus highlights.

MOTION

Animation

Disable interface transitions and animated state changes.

+
WRITES

Instant flash

Write each change to the mouse as soon as you make it, instead of staging it for the flash bar.

SECTIONS

Advanced editors

Choose whether CPI, button mapping, and experimental sections begin expanded.

EXPERIMENTAL

Experimental controls

Show or completely hide controls that may vary between firmware versions.

diff --git a/src/control.ts b/src/control.ts index 7191635d..03eeba26 100644 --- a/src/control.ts +++ b/src/control.ts @@ -29,7 +29,7 @@ import { } from "./pending-changes"; import { deviceImage } from "./ui/device-images"; import { formatHex, setControlValue, setSelected, setText, setToggleValue } from "./ui/dom"; -import { renderPendingBar, setPendingBarBusy, setPendingBarStatus } from "./ui/pending-bar"; +import { renderPendingBar, setPendingBarBusy, setPendingBarStatus, setPendingBarSuppressed } from "./ui/pending-bar"; import { DEFAULT_INTERFACE_PREFERENCES, loadInterfacePreferences, @@ -103,6 +103,7 @@ if (!controlApp) { const appRoot = controlApp; const BUILD_LABEL = `${__BUILD_CHANNEL__.toUpperCase()} · v${__APP_VERSION__}`; +const DEFAULT_TITLE = document.title; const previewMode = import.meta.env.DEV ? parsePreviewMode(new URLSearchParams(window.location.search).get("preview")) : null; @@ -227,9 +228,22 @@ function stageChange(change: PendingChange): void { } stagePendingChange(change); if (latestDeviceStatus) showStatus(latestDeviceStatus); + if (interfacePreferences.instantFlash) { + queueInstantFlash(); + return; + } setText("#read-status", `${change.label} staged. Flash to write it to the mouse.`); } +function queueInstantFlash(): void { + if (instantFlashQueued) return; + instantFlashQueued = true; + window.setTimeout(() => { + instantFlashQueued = false; + void flashPendingChanges(); + }); +} + // True when previewing the change over the device status would leave it unchanged function matchesDeviceStatus(change: PendingChange): boolean { if (!latestDeviceStatus) return false; @@ -306,6 +320,7 @@ async function flashPendingChanges(): Promise { } let interfacePreferences = loadInterfacePreferences(localStorage); +let instantFlashQueued = false; function saveInterfacePreferences(): void { persistInterfacePreferences(localStorage, interfacePreferences); @@ -313,6 +328,7 @@ function saveInterfacePreferences(): void { } function applyInterfacePreferences(): void { + setPendingBarSuppressed(interfacePreferences.instantFlash); const shell = document.querySelector(".control-shell"); if (!shell) return; shell.classList.toggle("density-comfortable", interfacePreferences.density === "Comfortable"); @@ -362,6 +378,10 @@ function renderControl(): void { interfacePreferences.reducedMotion = enabled; saveInterfacePreferences(); }, + setInstantFlash: (enabled) => { + interfacePreferences.instantFlash = enabled; + saveInterfacePreferences(); + }, setExpandSections: (enabled) => { interfacePreferences.expandSections = enabled; saveInterfacePreferences(); @@ -640,9 +660,11 @@ function populateInterfaceSettings(): void { setControlValue("#interface-theme", interfacePreferences.theme); const reducedMotion = document.querySelector("#interface-reduced-motion"); const expandSections = document.querySelector("#interface-expand-sections"); + const instantFlash = document.querySelector("#interface-instant-flash"); const showExperimental = document.querySelector("#interface-show-experimental"); if (reducedMotion) reducedMotion.checked = interfacePreferences.reducedMotion; if (expandSections) expandSections.checked = interfacePreferences.expandSections; + if (instantFlash) instantFlash.checked = interfacePreferences.instantFlash; if (showExperimental) showExperimental.checked = interfacePreferences.showExperimental; } @@ -992,6 +1014,10 @@ function downloadDiagnostics(): void { if (status) status.textContent = `Saved ${name}`; } +function setPageTitle(prefix?: string): void { + document.title = prefix ? `${prefix} - ${DEFAULT_TITLE}` : DEFAULT_TITLE; +} + function showStatus(deviceStatus: MouseStatus): void { latestDeviceStatus = deviceStatus; latestDiagnosticStatus = deviceStatus; @@ -1081,6 +1107,8 @@ function showStatus(deviceStatus: MouseStatus): void { processingCard.style.display = ui?.hideProcessingCard ? "none" : ""; } const battery = status.batteryPercent === null ? "—" : `${status.batteryPercent}%`; + const charging = batteryMode(status.batteryState) === "charging" ? "⚡" : ""; + setPageTitle(status.batteryPercent === null ? status.name : `${charging}${status.batteryPercent}% - ${status.name}`); const dpiOutputField = document.querySelector("#dpi-output"); if (dpiOutputField?.readOnly) dpiOutputField.value = `${status.dpi.toLocaleString()} DPI`; setText("#battery-value", battery); @@ -1601,6 +1629,7 @@ function showDisconnectedState(): void { if (advanced) advanced.style.display = "none"; document.querySelector(".control-shell")?.classList.add("is-empty"); document.querySelectorAll(".device-dot, .status-dot").forEach((dot) => dot.classList.add("is-idle")); + setPageTitle(); setText("#device-title", "Connect a mouse"); setText("#device-status", "No device connected"); setText("#read-status", "Add a supported device from the sidebar to read its current status."); @@ -3442,6 +3471,8 @@ const notice = unsupportedNotice({ secureContext: window.isSecureContext, chromium: isChromium(), }); +if (import.meta.env.PROD) void navigator.serviceWorker?.register("/sw.js").catch(() => undefined); + if (notice) { appRoot.innerHTML = unsupportedTemplate(notice); } else { diff --git a/src/interface-preferences.test.ts b/src/interface-preferences.test.ts index 3fe70a43..45114da0 100644 --- a/src/interface-preferences.test.ts +++ b/src/interface-preferences.test.ts @@ -21,6 +21,7 @@ test("interface preferences restore only supported values", () => { reducedMotion: true, expandSections: true, showExperimental: false, + instantFlash: true, }); assert.deepEqual(loadInterfacePreferences(storage), { @@ -29,6 +30,7 @@ test("interface preferences restore only supported values", () => { reducedMotion: true, expandSections: true, showExperimental: false, + instantFlash: true, }); }); diff --git a/src/interface-preferences.ts b/src/interface-preferences.ts index 50aa8461..c63ed04d 100644 --- a/src/interface-preferences.ts +++ b/src/interface-preferences.ts @@ -7,6 +7,7 @@ export interface InterfacePreferences { reducedMotion: boolean; expandSections: boolean; showExperimental: boolean; + instantFlash: boolean; } const STORAGE_KEY = "openmouse-interface-settings-v1"; @@ -18,6 +19,7 @@ export const DEFAULT_INTERFACE_PREFERENCES: InterfacePreferences = { reducedMotion: false, expandSections: false, showExperimental: true, + instantFlash: false, }; export function loadInterfacePreferences(storage: Storage): InterfacePreferences { @@ -29,6 +31,7 @@ export function loadInterfacePreferences(storage: Storage): InterfacePreferences reducedMotion: saved.reducedMotion === true, expandSections: saved.expandSections === true, showExperimental: saved.showExperimental !== false, + instantFlash: saved.instantFlash === true, }; } catch { return { ...DEFAULT_INTERFACE_PREFERENCES }; diff --git a/src/ui/pending-bar.ts b/src/ui/pending-bar.ts index fe819f76..fe67ca14 100644 --- a/src/ui/pending-bar.ts +++ b/src/ui/pending-bar.ts @@ -5,14 +5,34 @@ import { setText } from "./dom"; const HIDE_DELAY_MS = 220; let hideTimer: number | null = null; +let suppressed = false; function bar(): HTMLElement | null { return document.querySelector("#pending-changes-bar"); } +function hideNow(element: HTMLElement): void { + if (hideTimer !== null) { + window.clearTimeout(hideTimer); + hideTimer = null; + } + element.classList.remove("is-leaving"); + element.hidden = true; + document.querySelector(".control-shell")?.classList.remove("has-pending-changes"); +} + +export function setPendingBarSuppressed(value: boolean): void { + suppressed = value; + renderPendingBar(); +} + export function renderPendingBar(): void { const element = bar(); if (!element) return; + if (suppressed) { + hideNow(element); + return; + } const count = pendingChangeCount(); document.querySelector(".control-shell")?.classList.toggle("has-pending-changes", count > 0); if (count === 0) { From ce439a72229d69e4a592a487c6239bb3331f5bff Mon Sep 17 00:00:00 2001 From: nyedle <232406712+nyedle@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:41:55 +0300 Subject: [PATCH 3/3] UI --- public/logo.png | Bin 0 -> 1822 bytes src/control-template.ts | 7 ++----- src/control.css | 10 ++++------ 3 files changed, 6 insertions(+), 11 deletions(-) create mode 100644 public/logo.png diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1ae1dcf00c595a3d46480d086a7ff649dbbedd57 GIT binary patch literal 1822 zcmb7Fdo-H~7LPF=m3nO`LPn>ioJCY^$qwTE-jviMAzp2bbT$mFNNRM@PCbrQRo!}p zRFsg1go=;`#~M{*rlC_`nxte^yNiC2*tUaCtYpp1nRE89J!kK^_x$et-N#?|o?DBJ zk2W`hn?WED^Vpb(!~+yUAO?p`4S)86O>Q560XZ=`%s?^cS#b~?x)_s2hCraOpUnV5 z>##o{@mNf92Gtq0@Dn6Pl*EQ@OwP?Ur>|_USvSrswEh_mbu$Qx*_4=<*asKJ<8&0*` zhG`D@mrT&y0Kf%;Nzf* z-%&mlXCZT-fbHj1gopFoyeDvBB|Qb-l|nKI=;Md0fG??0nYB2MU~^b}gqN}|kEQq$ zc=LmQ1wmS=@JdYCADH|?-w=8}HH;Tx2*mmgJY!gcHtqO4voN5RE#ai_9u{6za}P;%DC4^ zLy9|uY%`W?`)QT|FN(t^!bl;*Zj0Xj9U!Ei9L=^@-3*>i2)~Di5_A#~p zF3JD~Crt^Qg{EH;%uMbjM_U@F1=ScEoE*KK_#HsTNSb}NostC1o&HCjTOEtIGHq+y zk1pHEouszXn`{N4_ihepq?x_mO5^2%pY{^-exn5mlJ}~4e%G{NjupoPGNKZ9Q>!-Yv4)L2yMxoXW@#~XL=gfQj=c=2pa=t`~ zAgSssT}h1gwu-Yc$`idByQJMZ_TCJz|KIc; z{`$O)ENIGXUwO*0gSN;x2$Vd<|?o|7(Rn1Ve2z`1B=&Cc8`l27>4ONIwd`blqwwHSg zoCxUm4~g>yfhB_ds-8W(B|1|6PHd^%Jj=Y#Zm-5$2HuC^hwVj*)ep3JSj&LO+xQuW z5e3)969dk}V}c_5`9i;*uO$}Mw~e(U0SfF zmiGYZ0@%i#(pS5U!0(e2!PD(@5+!lsctCADcQmK(2ZwE9QULi#?~T%K;spXdsc?F} zO8_F}X~kK}PI@#Hm{5E$9HDGMhSmXS#j#w$jCY=T7G^wL%EDZ z*|C>~{`4dgY~DOp+$L@tdz{f%C?*q!5(!#Hp82 zCZ*((G)?c8N|(cYZm
diff --git a/src/control.css b/src/control.css index a4172328..1f25ab52 100644 --- a/src/control.css +++ b/src/control.css @@ -63,10 +63,10 @@ button:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid .panel-title { display: flex; align-items: center; gap: .9rem; } .device-label, .overline { margin: 0 0 .7rem; color: #7f7f84; font-family: var(--font-mono); font-size: .63rem; letter-spacing: .1em; } -.demo-wordmark { margin-bottom: 4rem; font-size: 1.05rem; font-weight: 600; letter-spacing: -.025em; } -.build-identity { display: flex; align-items: center; gap: .65rem; margin-bottom: 4rem; } -.build-identity .demo-wordmark { margin-bottom: 0; } -.build-badge { display: inline-flex; align-items: center; min-height: 1.35rem; padding: .2rem .45rem; border: 1px solid color-mix(in srgb, var(--ui-accent) 35%, transparent); border-radius: 999px; background: var(--ui-accent-soft); color: var(--ui-accent); font-family: var(--font-mono); font-size: .52rem; font-weight: 700; letter-spacing: .07em; line-height: 1; white-space: nowrap; } +.demo-wordmark { display: flex; align-items: center; gap: .5rem; margin-bottom: 4rem; font-size: 1.05rem; font-weight: 600; letter-spacing: -.025em; } +.demo-wordmark img { width: auto; height: 1.5rem; } +.sidebar > .build-badge { margin-top: auto; } +.build-badge { color: var(--ghost); font-family: var(--font-mono); font-size: .62rem; letter-spacing: .06em; white-space: nowrap; } .device-select { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: .7rem; width: 100%; padding: .85rem; border: 1px solid #303034; border-radius: 10px; background: var(--raised); cursor: default; text-align: left; } .device-select strong, .device-select small { display: block; } @@ -94,7 +94,6 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } .nav-item:hover { background: #1b1b1e; color: #fff; } .control-shell .nav-item[aria-current="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); } .interface-settings-button::before { margin-right: .5rem; color: var(--ui-accent); content: "⚙"; font-size: .72rem; } -.sidebar-footer { display: grid; gap: .45rem; margin-top: auto; color: var(--ghost); font-size: .7rem; } .panel-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 1.5rem; padding: 4rem 0 2rem; } .panel-header h1 { max-width: min(760px, 70vw); margin: 0; font-size: clamp(2.5rem, 4.5vw, 4rem); font-weight: 500; line-height: .95; letter-spacing: -.04em; overflow-wrap: anywhere; } @@ -328,7 +327,6 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } @media (max-width: 899px) { .control-shell { display: block; } .control-shell .sidebar { position: static; height: auto; min-height: 0; border-right: 0; border-bottom: 1px solid var(--line); } - .control-shell .sidebar-footer { display: none; } .control-shell .control-panel { width: min(100% - 2rem, 1180px); } .control-shell.has-pending-changes .control-panel { padding-bottom: 7rem; } .pending-bar-copy small { display: none; }