Skip to content
Open
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
8 changes: 4 additions & 4 deletions src/app/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<li>
Expand All @@ -102,10 +102,10 @@ function SidebarDeviceRow({
<span className="om-side-device-text">
<span className="om-side-device-name">{device.name}</span>
<span className="om-side-device-sub">
{status?.batteryPercent != null ? (
{mouseStatus?.batteryPercent != null ? (
<>
<BatteryIcon percent={status.batteryPercent} state={status.batteryState} />
<span>{status.batteryPercent}%</span>
<BatteryIcon percent={mouseStatus.batteryPercent} state={mouseStatus.batteryState} />
<span>{mouseStatus.batteryPercent}%</span>
</>
) : (
<span>{live ? t(locale, "side.connected") : t(locale, "home.notConnected")}</span>
Expand Down
15 changes: 12 additions & 3 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
}
Expand Down
21 changes: 18 additions & 3 deletions src/features/buttons/ButtonMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> => {
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);
Expand Down
2 changes: 1 addition & 1 deletion src/features/capture/CaptureDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
);
Expand Down
9 changes: 7 additions & 2 deletions src/features/devices/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): Reac
const isNoirProfile = isNoirKsnakeStatus(status);
const profileImportRef = useRef<HTMLInputElement>(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 (
Expand Down Expand Up @@ -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);
});
}}
/>
</section>
Expand Down
14 changes: 7 additions & 7 deletions src/features/devices/cards/DpiCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
jazzstack marked this conversation as resolved.
Resolved
}, delay);
void next;
};

const flushEdit = (): void => {
Expand Down Expand Up @@ -284,7 +284,7 @@ function DpiStageEditor({
if (fixedStageCount) return;
setRows(next);
rowsRef.current = next;
markEdited(next);
markEdited();
};

const TYPING_DELAY = 1200;
Expand All @@ -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 } => {
Expand All @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion src/features/devices/cards/PerformanceCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 0 additions & 1 deletion src/features/devices/cards/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
48 changes: 42 additions & 6 deletions src/features/hardware-test/HardwareTestPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.` };
Expand Down Expand Up @@ -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,
}));
}
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
read: () => control.getSnapshot().status?.pollingRateHz ?? null,
}));
}
Expand All @@ -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,
}));
}
Expand Down
22 changes: 22 additions & 0 deletions src/features/news/news.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<script>alert(1)</script>", "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();
Expand Down
18 changes: 16 additions & 2 deletions src/features/news/news.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NewsItem | null> {
const response = await fetch(url, { signal, cache: "no-store" });
if (!response.ok) throw new Error(`News endpoint returned HTTP ${response.status}.`);
Expand All @@ -31,8 +46,7 @@ async function fetchNewsFrom(url: string, signal?: AbortSignal): Promise<NewsIte
const message = typeof body.message === "string" ? body.message.trim() : "";
if (!id || !message) return null;
const level: NewsLevel = LEVELS.includes(body.level as NewsLevel) ? (body.level as NewsLevel) : "info";
const link = typeof body.url === "string" && body.url.trim() ? body.url.trim() : undefined;
return { id, level, message, url: link };
return { id, level, message, url: safeNewsUrl(body.url) };
}

export async function fetchNews(signal?: AbortSignal): Promise<NewsItem | null> {
Expand Down
4 changes: 2 additions & 2 deletions src/features/settings/InterfaceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"))}
Expand Down
8 changes: 8 additions & 0 deletions src/features/settings/interface-preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
7 changes: 6 additions & 1 deletion src/features/settings/interface-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/features/superstrike/HitsTestDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
2 changes: 1 addition & 1 deletion src/platform/self-xss-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!";
Expand Down
Loading