diff --git a/src/app/AppSidebar.tsx b/src/app/AppSidebar.tsx index 186b1d57..9fe9f28e 100644 --- a/src/app/AppSidebar.tsx +++ b/src/app/AppSidebar.tsx @@ -76,7 +76,7 @@ function SidebarDeviceRow({ const device = snapshot.devices.find((entry) => entry.index === index); if (!device) return null; const live = device.selected && (snapshot.deviceStatus !== null || snapshot.keyboard !== null); - const status = live ? snapshot.deviceStatus : null; + const mouseStatus = live ? snapshot.deviceStatus : null; const image = deviceImage({ vendorId: device.vendorId, productId: device.productId } as HIDDevice, device.name); return (
  • @@ -102,10 +102,10 @@ function SidebarDeviceRow({ {device.name} - {status?.batteryPercent != null ? ( + {mouseStatus?.batteryPercent != null ? ( <> - - {status.batteryPercent}% + + {mouseStatus.batteryPercent}% ) : ( {live ? t(locale, "side.connected") : t(locale, "home.notConnected")} diff --git a/src/device/controller.ts b/src/device/controller.ts index 35cb8aaf..9f6c4c04 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -280,8 +280,13 @@ let keyboardWorkspace = emptyKeyboardWorkspace(); let keyboardBusy = false; let keyboardStatusText: string | null = null; let x11PollingRateHz = (() => { - const saved = Number(window.localStorage.getItem(X11_POLLING_STORAGE_KEY)); - return X11_POLLING_RATES_HZ.includes(saved) ? saved : 1000; + try { + const saved = Number(window.localStorage.getItem(X11_POLLING_STORAGE_KEY)); + return X11_POLLING_RATES_HZ.includes(saved) ? saved : 1000; + } catch { + // Storage can be unavailable (private mode, blocked cookies). + return 1000; + } })(); /** True for the specific Attack Shark X11 units whose settings channel is native-only. */ @@ -5441,7 +5446,11 @@ export function applyPollingRate(rate: number): void { if (nativeX11) { await applyBridgeNativeSettings({ brand: "Attack Shark", pollingRateHz: rate }); x11PollingRateHz = rate; - window.localStorage.setItem(X11_POLLING_STORAGE_KEY, String(rate)); + try { + window.localStorage.setItem(X11_POLLING_STORAGE_KEY, String(rate)); + } catch { + // The rate is applied; persistence is best effort. + } } else { await requireClientMethod("setPollingRate", "the polling rate").setPollingRate(rate); } diff --git a/src/features/buttons/ButtonMap.tsx b/src/features/buttons/ButtonMap.tsx index 13b5f2d1..10ff63d8 100644 --- a/src/features/buttons/ButtonMap.tsx +++ b/src/features/buttons/ButtonMap.tsx @@ -92,8 +92,19 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re setRecording(false); return; } + const isModifierKey = event.code === "ShiftLeft" || event.code === "ShiftRight" || event.code === "ControlLeft" || event.code === "ControlRight" || event.code === "AltLeft" || event.code === "AltRight" || event.code === "MetaLeft" || event.code === "MetaRight" || event.code === "OSLeft" || event.code === "OSRight"; + if (isModifierKey) { + return; + } const binding = bindingFromKeyEvent(event); - if (!binding || binding.kind !== "keyboard") return; + if (!binding) { + setRecording(false); + return; + } + if (binding.kind !== "keyboard") { + setRecording(false); + return; + } void control.applyLogitechButtonAssignment(layer, selected, binding); setPending((previous) => ({ ...previous, [`${layer}-${selected}`]: shortcutText(binding.key, binding.modifiers) })); setRecording(false); @@ -132,8 +143,12 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re const defaultFor = (button: number): LogitechButtonAction => (layer === "primary" ? DEFAULT_ACTION[button] : "Disabled") as LogitechButtonAction; const reset = async (buttons: number[]): Promise => { for (const button of buttons) { - await control.applyLogitechButtonAssignment(layer, button, defaultFor(button)); - setPending((previous) => ({ ...previous, [`${layer}-${button}`]: defaultFor(button) })); + try { + await control.applyLogitechButtonAssignment(layer, button, defaultFor(button)); + setPending((previous) => ({ ...previous, [`${layer}-${button}`]: defaultFor(button) })); + } catch { + // Keep going; failure is reported through the existing pending/flash flow. + } } }; const unlocked = layout.spots.filter((spot) => !isLocked(spot)).map((spot) => spot.button); diff --git a/src/features/capture/CaptureDialog.tsx b/src/features/capture/CaptureDialog.tsx index d3766a5a..a0b241e0 100644 --- a/src/features/capture/CaptureDialog.tsx +++ b/src/features/capture/CaptureDialog.tsx @@ -207,7 +207,7 @@ export function CaptureDialog({ open, onClose, locale = "en" }: { open: boolean; diffs, sectors, }); - void navigator.clipboard.writeText(markdown).then( + void (navigator.clipboard?.writeText(markdown) ?? Promise.reject(new Error("Clipboard unavailable."))).then( () => setMessage("Capture copied — paste it into a GitHub issue."), () => setMessage("Could not copy to the clipboard."), ); diff --git a/src/features/devices/OverviewPage.tsx b/src/features/devices/OverviewPage.tsx index a2ffe46b..22d371fc 100644 --- a/src/features/devices/OverviewPage.tsx +++ b/src/features/devices/OverviewPage.tsx @@ -141,7 +141,7 @@ function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): Reac const isNoirProfile = isNoirKsnakeStatus(status); const profileImportRef = useRef(null); if (!status || !isNoirProfile || snapshot.m2nexProfiles === null) return null; - const profile = snapshot.m2nexProfiles[snapshot.activeM2NexProfile]; + const profile = snapshot.m2nexProfiles[snapshot.activeM2NexProfile] ?? snapshot.m2nexProfiles[0]; if (!profile) return null; const applyDisabled = snapshot.settingInProgress || snapshot.pending.count > 0; return ( @@ -206,7 +206,12 @@ function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): Reac const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (!file) return; - void file.text().then((text) => control.importM2NexProfiles(text)); + void file + .text() + .then((text) => control.importM2NexProfiles(text)) + .catch((error) => { + control.pushToast("error", "Import failed", error instanceof Error ? error.message : undefined); + }); }} /> diff --git a/src/features/devices/cards/DpiCard.tsx b/src/features/devices/cards/DpiCard.tsx index efd26b1e..d8e1a1b1 100644 --- a/src/features/devices/cards/DpiCard.tsx +++ b/src/features/devices/cards/DpiCard.tsx @@ -239,14 +239,14 @@ function DpiStageEditor({ } }; - const markEdited = (next: StageRow[], delay = 450): void => { + const markEdited = (delay = 450, nextRows?: StageRow[]): void => { touchedRef.current = Date.now(); + const snapshotRows = nextRows ?? rowsRef.current; if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { timerRef.current = null; - commit(rowsRef.current); + commit(snapshotRows); }, delay); - void next; }; const flushEdit = (): void => { @@ -284,7 +284,7 @@ function DpiStageEditor({ if (fixedStageCount) return; setRows(next); rowsRef.current = next; - markEdited(next); + markEdited(); }; const TYPING_DELAY = 1200; @@ -296,7 +296,7 @@ function DpiStageEditor({ : row)); setRows(next); rowsRef.current = next; - markEdited(next, delay); + markEdited(delay); }; const inputEdit = (event: Event, oldRaw: string): { raw: string; delay?: number } => { @@ -321,14 +321,14 @@ function DpiStageEditor({ }); setRows(next); rowsRef.current = next; - markEdited(next); + markEdited(); }; const setValueY = (index: number, raw: string, delay?: number): void => { const next = rows.map((row, i) => (i === index ? { ...row, valueY: raw.replace(/[^\d]/g, "") } : row)); setRows(next); rowsRef.current = next; - markEdited(next, delay); + markEdited(delay); }; const setSlider = (index: number, numeric: number): void => setValue(index, String(numeric)); diff --git a/src/features/devices/cards/PerformanceCards.tsx b/src/features/devices/cards/PerformanceCards.tsx index 2d65ebc2..eccbaa47 100644 --- a/src/features/devices/cards/PerformanceCards.tsx +++ b/src/features/devices/cards/PerformanceCards.tsx @@ -496,7 +496,7 @@ export function DynamicSensitivityCard({ snapshot }: { snapshot: ControlSnapshot const customPoints = draft ?? curves?.custom ?? null; const updateDraftPoint = (index: number, field: "speed" | "gain", value: number): void => { - const base = (curves?.custom ?? []).map((point) => ({ ...point })); + const base = (draft ?? curves?.custom ?? []).map((point) => ({ ...point })); while (base.length < 5) base.push({ speed: 0, gain: 1 }); base[index] = { ...base[index]!, [field]: value }; setDraft(base); diff --git a/src/features/devices/cards/availability.ts b/src/features/devices/cards/availability.ts index 0bbe82c7..6eb18d0b 100644 --- a/src/features/devices/cards/availability.ts +++ b/src/features/devices/cards/availability.ts @@ -136,7 +136,6 @@ function computeCardAvailability(snapshot: ControlSnapshot): CardAvailability { || status.angleTuning != null || status.longRangeMode != null || status.sensorMode != null || status.performanceDuration != null - || status.longRangeMode != null ); const eggs = host && traits.eggControls; diff --git a/src/features/hardware-test/HardwareTestPage.tsx b/src/features/hardware-test/HardwareTestPage.tsx index e249a2a0..46b4a56d 100644 --- a/src/features/hardware-test/HardwareTestPage.tsx +++ b/src/features/hardware-test/HardwareTestPage.tsx @@ -257,7 +257,9 @@ export function HardwareTestPage({ snapshot }: { snapshot: ControlSnapshot }): R return { setting: leg.setting, current: leg.current, target: leg.target, status: "fail", detail: `wrote ${leg.target} but read back something else — the write did not stick.` }; } try { - leg.stageRestore(); + if (!leg.stageRestore()) { + return { setting: leg.setting, current: leg.current, target: leg.target, status: "fail", detail: `restore to ${leg.current} failed after reading back ${leg.target} — device settings may have changed.` }; + } await control.flashPendingChanges(); if (leg.read() !== leg.currentRaw) { return { setting: leg.setting, current: leg.current, target: leg.target, status: "fail", detail: `restore to ${leg.current} failed after reading back ${leg.target} — device settings may have changed.` }; @@ -298,7 +300,13 @@ export function HardwareTestPage({ snapshot }: { snapshot: ControlSnapshot }): R currentRaw: dpiCurrent, targetRaw: dpiTarget, stageTarget: () => control.applyDpiValue(dpiTarget), - stageRestore: () => control.applyDpiValue(dpiCurrent), + stageRestore: () => { + try { + return control.applyDpiValue(dpiCurrent); + } catch { + return false; + } + }, read: () => control.getSnapshot().status?.dpi ?? null, })); } @@ -320,8 +328,22 @@ export function HardwareTestPage({ snapshot }: { snapshot: ControlSnapshot }): R note: "the reporting rate changes briefly", currentRaw: rateCurrent, targetRaw: rateTarget, - stageTarget: () => { control.applyPollingRate(rateTarget); return true; }, - stageRestore: () => { control.applyPollingRate(rateCurrent); return true; }, + stageTarget: () => { + try { + control.applyPollingRate(rateTarget); + return true; + } catch { + return false; + } + }, + stageRestore: () => { + try { + control.applyPollingRate(rateCurrent); + return true; + } catch { + return false; + } + }, read: () => control.getSnapshot().status?.pollingRateHz ?? null, })); } @@ -343,8 +365,22 @@ export function HardwareTestPage({ snapshot }: { snapshot: ControlSnapshot }): R note: "the sensor lift height changes briefly", currentRaw: lodCurrent, targetRaw: lodTarget, - stageTarget: () => { control.applyLiftOffDistance(lodTarget); return true; }, - stageRestore: () => { control.applyLiftOffDistance(lodCurrent); return true; }, + stageTarget: () => { + try { + control.applyLiftOffDistance(lodTarget); + return true; + } catch { + return false; + } + }, + stageRestore: () => { + try { + control.applyLiftOffDistance(lodCurrent); + return true; + } catch { + return false; + } + }, read: () => control.getSnapshot().status?.liftOffDistance ?? null, })); } diff --git a/src/features/news/news.test.ts b/src/features/news/news.test.ts index 2ec736c0..5d328858 100644 --- a/src/features/news/news.test.ts +++ b/src/features/news/news.test.ts @@ -82,6 +82,28 @@ test("a payload without a message or id produces no banner", async () => { ); }); +test("non-web notice links are dropped instead of reaching the banner anchor", async () => { + for (const url of ["javascript:alert(1)", "data:text/html,", "vbscript:msgbox(1)"]) { + await withFetch( + async () => Response.json({ id: "x", message: "Hello", url }), + async () => { + assert.equal((await fetchNews())?.url, undefined, url); + }, + ); + } +}); + +test("web and relative notice links are kept", async () => { + for (const url of ["https://openmouse.app/blog", "http://example.test/note", "/news/item"]) { + await withFetch( + async () => Response.json({ id: "x", message: "Hello", url }), + async () => { + assert.equal((await fetchNews())?.url, url); + }, + ); + } +}); + test("an aborted request does not retry against the fallback", async () => { const requested: string[] = []; const controller = new AbortController(); diff --git a/src/features/news/news.ts b/src/features/news/news.ts index 0ee92bd0..475c8cd4 100644 --- a/src/features/news/news.ts +++ b/src/features/news/news.ts @@ -23,6 +23,21 @@ const NEWS_FALLBACK_URL = "/news.json"; const LEVELS: NewsLevel[] = ["info", "warning", "critical"]; +/** The notice text comes from a remote JSON file, so its link must not be able + to become a `javascript:` (or other non-web) URL in the banner anchor. */ +function safeNewsUrl(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const raw = value.trim(); + if (!raw) return undefined; + try { + const parsed = new URL(raw, "https://openmouse.app/"); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined; + return raw; + } catch { + return undefined; + } +} + async function fetchNewsFrom(url: string, signal?: AbortSignal): Promise { const response = await fetch(url, { signal, cache: "no-store" }); if (!response.ok) throw new Error(`News endpoint returned HTTP ${response.status}.`); @@ -31,8 +46,7 @@ async function fetchNewsFrom(url: string, signal?: AbortSignal): Promise { diff --git a/src/features/settings/InterfaceSettings.tsx b/src/features/settings/InterfaceSettings.tsx index 522be54c..f4dda809 100644 --- a/src/features/settings/InterfaceSettings.tsx +++ b/src/features/settings/InterfaceSettings.tsx @@ -48,11 +48,11 @@ export function ProfileKeyFields({ snapshot }: { snapshot: ControlSnapshot }): R disabled={!key} onClick={() => { if (!key) return; - void navigator.clipboard.writeText(key).then(() => { + void navigator.clipboard?.writeText(key).then(() => { track("profile_key_copied"); setCopied(true); setTimeout(() => setCopied(false), 1500); - }); + }).catch(() => undefined); }} > {displayText(locale, copied ? t(locale, "set.copied") : t(locale, "set.copyKey"))} diff --git a/src/features/settings/interface-preferences.test.ts b/src/features/settings/interface-preferences.test.ts index 3e3b83cf..c7ae7064 100644 --- a/src/features/settings/interface-preferences.test.ts +++ b/src/features/settings/interface-preferences.test.ts @@ -20,6 +20,14 @@ class MemoryStorage implements Storage { setItem(key: string, value: string): void { this.#values.set(key, value); } } +test("a failing storage write does not break the settings update", () => { + const storage = new MemoryStorage(); + storage.setItem = () => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }; + assert.doesNotThrow(() => saveInterfacePreferences(storage, DEFAULT_INTERFACE_PREFERENCES)); +}); + test("interface preferences restore only supported values", () => { const storage = new MemoryStorage(); saveInterfacePreferences(storage, { diff --git a/src/features/settings/interface-preferences.ts b/src/features/settings/interface-preferences.ts index f0ee43d8..a3658b26 100644 --- a/src/features/settings/interface-preferences.ts +++ b/src/features/settings/interface-preferences.ts @@ -129,7 +129,12 @@ export function loadInterfacePreferences(storage: Storage): InterfacePreferences } export function saveInterfacePreferences(storage: Storage, preferences: InterfacePreferences): void { - storage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + try { + storage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + } catch { + // Storage can be unavailable or full (private mode, blocked cookies). + // Losing persistence is preferable to breaking the settings UI. + } } /** Dataset value for the theme selector. Display names carry spaces, one diff --git a/src/features/superstrike/HitsTestDialog.tsx b/src/features/superstrike/HitsTestDialog.tsx index e2051ba3..4fbf3fa1 100644 --- a/src/features/superstrike/HitsTestDialog.tsx +++ b/src/features/superstrike/HitsTestDialog.tsx @@ -99,7 +99,7 @@ export function HitsTestDialog({ setNote("Too early. Wait for blue."); setPhase("idle"); } else if (phase === "go") { - const reaction = event.timeStamp - goAt.current; + const reaction = performance.now() - goAt.current; setPhase("settle"); timer.current = window.setTimeout(() => { const saved = savedMs(samples.current, actuationRef.current[sideRef.current]); diff --git a/src/platform/self-xss-guard.ts b/src/platform/self-xss-guard.ts index cb247c72..42258d0a 100644 --- a/src/platform/self-xss-guard.ts +++ b/src/platform/self-xss-guard.ts @@ -11,7 +11,7 @@ * debugging, and misfire on browsers with wide chrome (e.g. Firefox forks * with vertical tabs or sidebars). * - * Call only on production builds (see control.tsx). + * Call only on production builds (see main.tsx). */ const WARNING_TITLE = "Stop!";