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!";