Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedpublic/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions public/sw.js
Original file line numberDiff line numberDiff line change
@@ -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;
})());
});
4 changes: 4 additions & 0 deletions src/control-events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -110,6 +111,9 @@ export function bindControlEvents(handlers: ControlEventHandlers): void {
document.querySelector<HTMLInputElement>("#interface-reduced-motion")?.addEventListener("change", (event) => {
handlers.setReducedMotion((event.target as HTMLInputElement).checked);
});
document.querySelector<HTMLInputElement>("#interface-instant-flash")?.addEventListener("change", (event) => {
handlers.setInstantFlash((event.target as HTMLInputElement).checked);
});
document.querySelector<HTMLInputElement>("#interface-expand-sections")?.addEventListener("change", (event) => {
handlers.setExpandSections((event.target as HTMLInputElement).checked);
});
Expand Down
8 changes: 3 additions & 5 deletions src/control-template.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,7 @@ export function controlTemplate(buildLabel: string): string {
return `
<div class="control-shell is-empty">
<aside class="sidebar">
<div class="build-identity">
<span class="demo-wordmark">OpenMouse</span>
<span class="build-badge" title="OpenMouse ${buildLabel}">${buildLabel}</span>
</div>
<span class="demo-wordmark"><img src="/logo.png" alt="" width="181" height="268" />OpenMouse</span>
<div class="device-label">CONNECTED DEVICES</div>
<div id="sidebar-device-list" class="sidebar-device-list">
<div class="device-select">
Expand All@@ -29,7 +26,7 @@ export function controlTemplate(buildLabel: string): string {
<nav aria-label="Sections">
<button id="interface-settings-button" class="nav-item interface-settings-button" type="button" aria-current="false">Interface settings</button>
</nav>
<div class="sidebar-footer"><span>WebHID device control</span><span>OpenMouse Control</span></div>
<span class="build-badge" title="OpenMouse ${buildLabel}">${buildLabel}</span>
</aside>

<main class="control-panel">
Expand DownExpand Up@@ -112,6 +109,7 @@ export function controlTemplate(buildLabel: string): string {
<article class="interface-setting-card"><span>LAYOUT</span><h3>Interface density</h3><p>Choose tighter controls or add more breathing room throughout the panel.</p><select id="interface-density"><option>Compact</option><option>Comfortable</option></select></article>
<article class="interface-setting-card"><span>APPEARANCE</span><h3>Accent theme</h3><p>Customize active controls, status lights, switches, and focus highlights.</p><select id="interface-theme"><option>Emerald</option><option>Violet</option><option>Ice</option><option>Ember</option><option>Mono</option></select><div class="theme-preview" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div></article>
<article class="interface-setting-card"><span>MOTION</span><h3>Animation</h3><p>Disable interface transitions and animated state changes.</p><label class="interface-switch-row"><span>Reduce motion</span><input id="interface-reduced-motion" type="checkbox" /></label></article>
<article class="interface-setting-card"><span>WRITES</span><h3>Instant flash</h3><p>Write each change to the mouse as soon as you make it, instead of staging it for the flash bar.</p><label class="interface-switch-row"><span>Flash immediately</span><input id="interface-instant-flash" type="checkbox" /></label></article>
<article class="interface-setting-card"><span>SECTIONS</span><h3>Advanced editors</h3><p>Choose whether CPI, button mapping, and experimental sections begin expanded.</p><label class="interface-switch-row"><span>Expand by default</span><input id="interface-expand-sections" type="checkbox" /></label></article>
<article class="interface-setting-card"><span>EXPERIMENTAL</span><h3>Experimental controls</h3><p>Show or completely hide controls that may vary between firmware versions.</p><label class="interface-switch-row"><span>Show experimental settings</span><input id="interface-show-experimental" type="checkbox" /></label></article>
</div>
Expand Down
10 changes: 4 additions & 6 deletions src/control.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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; }
Expand DownExpand Up@@ -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; }
Expand DownExpand Up@@ -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; }
Expand Down
33 changes: 32 additions & 1 deletion src/control.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -306,13 +320,15 @@ async function flashPendingChanges(): Promise<void> {
}

let interfacePreferences = loadInterfacePreferences(localStorage);
let instantFlashQueued = false;

function saveInterfacePreferences(): void {
persistInterfacePreferences(localStorage, interfacePreferences);
applyInterfacePreferences();
}

function applyInterfacePreferences(): void {
setPendingBarSuppressed(interfacePreferences.instantFlash);
const shell = document.querySelector<HTMLElement>(".control-shell");
if (!shell) return;
shell.classList.toggle("density-comfortable", interfacePreferences.density === "Comfortable");
Expand DownExpand Up@@ -362,6 +378,10 @@ function renderControl(): void {
interfacePreferences.reducedMotion = enabled;
saveInterfacePreferences();
},
setInstantFlash: (enabled) => {
interfacePreferences.instantFlash = enabled;
saveInterfacePreferences();
},
setExpandSections: (enabled) => {
interfacePreferences.expandSections = enabled;
saveInterfacePreferences();
Expand DownExpand Up@@ -640,9 +660,11 @@ function populateInterfaceSettings(): void {
setControlValue("#interface-theme", interfacePreferences.theme);
const reducedMotion = document.querySelector<HTMLInputElement>("#interface-reduced-motion");
const expandSections = document.querySelector<HTMLInputElement>("#interface-expand-sections");
const instantFlash = document.querySelector<HTMLInputElement>("#interface-instant-flash");
const showExperimental = document.querySelector<HTMLInputElement>("#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;
}

Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -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<HTMLInputElement>("#dpi-output");
if (dpiOutputField?.readOnly) dpiOutputField.value = `${status.dpi.toLocaleString()} DPI`;
setText("#battery-value", battery);
Expand DownExpand Up@@ -1601,6 +1629,7 @@ function showDisconnectedState(): void {
if (advanced) advanced.style.display = "none";
document.querySelector<HTMLElement>(".control-shell")?.classList.add("is-empty");
document.querySelectorAll<HTMLElement>(".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.");
Expand DownExpand Up@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/devices/registry.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 })
Expand Down
55 changes: 55 additions & 0 deletions src/devices/wlmouse/hid.test.ts
Original file line numberDiff line numberDiff line change
@@ -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}`);
});
19 changes: 10 additions & 9 deletions src/devices/wlmouse/hid.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;

Expand DownExpand Up@@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/interface-preferences.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ test("interface preferences restore only supported values", () => {
reducedMotion: true,
expandSections: true,
showExperimental: false,
instantFlash: true,
});

assert.deepEqual(loadInterfacePreferences(storage), {
Expand All@@ -29,6 +30,7 @@ test("interface preferences restore only supported values", () => {
reducedMotion: true,
expandSections: true,
showExperimental: false,
instantFlash: true,
});
});

Expand Down
3 changes: 3 additions & 0 deletions src/interface-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ export interface InterfacePreferences {
reducedMotion: boolean;
expandSections: boolean;
showExperimental: boolean;
instantFlash: boolean;
}

const STORAGE_KEY = "openmouse-interface-settings-v1";
Expand All@@ -18,6 +19,7 @@ export const DEFAULT_INTERFACE_PREFERENCES: InterfacePreferences = {
reducedMotion: false,
expandSections: false,
showExperimental: true,
instantFlash: false,
};

export function loadInterfacePreferences(storage: Storage): InterfacePreferences {
Expand All@@ -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 };
Expand Down
Loading