From 2678d02468e1fbe407ea20e474427be99cac4512 Mon Sep 17 00:00:00 2001 From: Emre Demir <274893088+EmreO33@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:52:39 +0300 Subject: [PATCH] Keep battery and connection type live: refresh by visibility, follow the cable Two user reports against the same root cause: the connected device's status only ever moved while the window was focused and the Performance tab was closed, and nothing re-scanned interfaces once something was connected. In practice battery stayed frozen while glancing at the app from another window or while sitting on the Performance tab, and plugging the cable in or out (a different product id over USB than over the receiver) was not noticed until the app was relaunched. Refresh cadence now follows what the user can see instead of stopping: 5 s focused (unchanged), 15 s visible but unfocused, 30 s with the Performance tab open, 60 s hidden/minimized. The Performance tab's staged values only reset when the device value actually changes, so an unchanged read has no visible effect there. A 3 s interface enumeration (nothing opened) drives transport hot-swap: - Connected interface vanished: connect to the same mouse on its sibling transport if present; otherwise drop to the list and keep watching, reconnecting on its own when the mouse or its sibling is back. - Sibling appeared while on the receiver: read it, confirm it is the same unit (unitId, after a product-string pre-filter), and move to the wired side. Dongle inserted while wired does not switch. - Launch-time auto-reconnect also accepts the remembered mouse's sibling. Verified on a DeathAdder V3 Pro (0x00b6 wired / 0x00b7 receiver): dongle pulled -> "disconnected" within a scan; cable in -> auto-reconnected wired; dongle back in -> stayed wired; cable out -> wireless in ~3 s; cable in -> wired in ~3 s. All without touching the app. --- src/hooks/use-mouse-connection.ts | 206 +++++++++++++++++++++++++++--- 1 file changed, 189 insertions(+), 17 deletions(-) diff --git a/src/hooks/use-mouse-connection.ts b/src/hooks/use-mouse-connection.ts index 9e57e9c..4869387 100644 --- a/src/hooks/use-mouse-connection.ts +++ b/src/hooks/use-mouse-connection.ts @@ -36,13 +36,31 @@ import { showToast } from "../lib/toast"; // manual refresh is still running just silently skips (isHidBusyError) // rather than piling up or corrupting anything. const AUTO_REFRESH_INTERVAL_MS = 5000; -// How often to re-read while nobody can see the window (minimized, hidden to -// the tray, or just not the focused app). The tray menu shows the battery -// from the cached status (see tray.rs), and a level frozen at whatever it was -// when the window was last looked at is what Synapse's tray notably does NOT -// do. One full walk a minute is cheap enough to keep that line honest -// without paying the 5 s cadence for a panel nobody is watching. +// Slower cadences for the states where the 5 s walk is more than the +// situation is worth, chosen so the status never stops moving altogether. +// Before these existed the refresh simply *skipped* whenever the window was +// unfocused or the Performance tab was open, and the practical result (a real +// user report) was a battery figure and a Wired/Wireless label that only +// changed after quitting and relaunching the app. The window is very often +// visible while another app has focus, and the Performance tab is where +// people actually sit. +// +// - Visible but not the focused window: someone may well be glancing at it. +// - Performance tab open: a landing read used to look like a reconnect while +// the user was mid-edit, so it stays gentle. The tab's staged values only +// reset when the device value actually changes (DevicePerformanceTab.tsx), +// so a read that comes back unchanged costs nothing on screen. +// - Hidden (minimized / tray): only the tray menu shows anything (tray.rs). +const UNFOCUSED_REFRESH_INTERVAL_MS = 15_000; +const EDITING_REFRESH_INTERVAL_MS = 30_000; const HIDDEN_REFRESH_INTERVAL_MS = 60_000; +// How often to re-enumerate HID interfaces while something is connected. A +// plain hidapi device-list refresh, nothing is opened, so it is cheap enough +// to run this often. It is what notices the cable going in or out: the mouse +// enumerates as a *different* product id over USB than over its receiver, so +// "wireless -> wired" is not a status change on the connected interface, it +// is a new interface appearing and the old one going quiet. +const TRANSPORT_SCAN_INTERVAL_MS = 3000; interface ConflictingApp { process: string; @@ -81,6 +99,31 @@ export type CandidateListState = | { status: "loaded"; candidates: CandidateInterface[] } | { status: "error"; message: string }; +/** + * Whether two interfaces are plausibly the same physical mouse reached over + * different transports (cable vs. receiver). Vendors give the two separate + * product ids but the same product string, sometimes with a transport word + * bolted on ("(Wired)", "Wireless", "Dongle"), so compare on the name with + * those stripped. This is only the *pre-filter*: the status read that follows + * confirms with the unit id / serial where the driver reports one. + */ +export function isSiblingTransport( + a: Pick, + b: Pick, +): boolean { + if (a.key === b.key || a.vendorId !== b.vendorId) return false; + const norm = (name: string) => + name + .toLowerCase() + .replace(/\b(wired|wireless|dongle|receiver|hyperspeed|lightspeed|2\.4g(?:hz)?|usb)\b/g, "") + .replace(/[()\-_]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const na = norm(a.productString); + const nb = norm(b.productString); + return na.length > 0 && na === nb; +} + export function useMouseConnection() { const [list, setList] = useState({ status: "loading" }); const [connected, setConnected] = useState(null); @@ -125,6 +168,19 @@ export function useMouseConnection() { // When the last successful walk finished, so the hidden-window cadence // below can be measured from real reads rather than from interval ticks. const lastReadAtRef = useRef(0); + // Interface keys seen by the last transport scan, so a scan can tell a + // newly-appeared interface from one that has been sitting there all along. + const seenKeysRef = useRef | null>(null); + // A transport switch is a connect() of its own; never start a second one + // while the first is still walking the device. + const switchingRef = useRef(false); + // The interface we were on when the mouse vanished mid-session (cable or + // dongle pulled). While set, the scan below keeps looking for it, or its + // sibling transport, and reconnects on its own when either shows up. The + // launch-time auto-reconnect is deliberately one-shot (see refresh()); this + // is the in-session counterpart, scoped to a device that was connected and + // then physically went away, not to whatever the user last browsed. + const awaitingReturnRef = useRef(null); const connect = useCallback(async (candidate: CandidateInterface, opts?: { silent?: boolean }) => { const key = candidate.info.key; @@ -137,6 +193,7 @@ export function useMouseConnection() { try { const device = await connectToInterface(candidate.info); lastReadAtRef.current = Date.now(); + awaitingReturnRef.current = null; setConnected(device); lastCandidateRef.current = candidate; rememberDevice(candidate.info, device.brand); @@ -207,7 +264,11 @@ export function useMouseConnection() { if (!autoReconnectAttempted.current && !connectedRef.current) { autoReconnectAttempted.current = true; const remembered = getRememberedDevice(); - const match = remembered && candidates.find((c) => c.info.key === remembered.key); + // Exact key first; failing that, the same mouse on its other transport + // (remembered over the cable, launched with only the receiver in, say). + const match = remembered + && (candidates.find((c) => c.info.key === remembered.key) + ?? candidates.find((c) => isSiblingTransport(c.info, remembered))); // Silent: a remembered device that's simply not plugged in yet // shouldn't greet the user with an error toast on every launch. if (match) void connect(match, { silent: true }); @@ -271,22 +332,133 @@ export function useMouseConnection() { useEffect(() => { if (!connected) return; const interval = setInterval(() => { - if (autoRefreshPausedRef.current) return; - // A full readStatus() walk (5 splits opened, 20-30 HID++ round - // trips) every 5s adds up if it keeps running while nobody can even - // see the result — minimized, hidden to the tray, occluded, or just - // not the focused window right now. Drop to the slow cadence there - // (the tray menu still shows the battery from this cache) rather - // than spend the full rate on a window nobody's actively looking at. - if (document.hidden || !windowFocusedRef.current) { - if (Date.now() - lastReadAtRef.current < HIDDEN_REFRESH_INTERVAL_MS) return; - } + // A full readStatus() walk (5 splits opened, 20-30 HID++ round trips) + // every 5 s adds up when nobody is looking at the result, so the + // cadence follows what the user can currently see. Whichever slow + // state applies, the read still happens eventually: the old behaviour + // of skipping outright is what left battery and connection type stale + // until a relaunch. The tick itself stays at 5 s; the slower states + // just let more ticks go by since the last successful read. + let due = AUTO_REFRESH_INTERVAL_MS; + if (document.hidden) due = HIDDEN_REFRESH_INTERVAL_MS; + else if (!windowFocusedRef.current) due = UNFOCUSED_REFRESH_INTERVAL_MS; + if (autoRefreshPausedRef.current) due = Math.max(due, EDITING_REFRESH_INTERVAL_MS); + if (Date.now() - lastReadAtRef.current < due) return; + if (switchingRef.current) return; if (lastCandidateRef.current) void connect(lastCandidateRef.current, { silent: true }); }, AUTO_REFRESH_INTERVAL_MS); return () => clearInterval(interval); // eslint-disable-next-line react-hooks/exhaustive-deps }, [connected?.key, connect]); + // Transport hot-swap. Re-enumerates interfaces on a short timer (nothing is + // opened) and reacts to two events for the connected mouse: + // + // 1. Its interface disappeared (cable unplugged, receiver pulled): connect + // to the same mouse on whatever sibling transport is still present, or + // fall back to the device list if there is none. + // 2. A sibling interface appeared (cable plugged into a mouse that was on + // its receiver): read it, and if the unit id says it is the same mouse, + // move to it when it is the wired side. Wired is where the mouse really + // is at that point (the receiver often keeps answering from a cache), + // and it is the only side that reports charging. The reverse (dongle + // inserted while wired) deliberately does not switch. + // + // Both used to need a quit and relaunch, because nothing re-scanned once + // something was connected and the auto-reconnect only ever ran once. + const [awaitingReturn, setAwaitingReturn] = useState(false); + useEffect(() => { + if (!connected && !awaitingReturn) { + seenKeysRef.current = null; + return; + } + const interval = setInterval(async () => { + if (switchingRef.current) return; + let candidates: CandidateInterface[]; + try { + candidates = await listCandidateInterfaces(); + } catch { + return; + } + // 0. Waiting for a mouse that went away: reconnect the moment it, or + // its other transport, is back. Silent connect; the success toast + // and the switch to the device view come from connect() itself. + const awaiting = awaitingReturnRef.current; + if (!connectedRef.current) { + if (!awaiting) return; + const back = candidates.find((c) => c.info.key === awaiting.key) + ?? candidates.find((c) => isSiblingTransport(c.info, awaiting)); + if (back) { + setList((prev) => (prev.status === "loaded" ? { status: "loaded", candidates } : prev)); + await connect(back); + if (connectedRef.current) setAwaitingReturn(false); + } + return; + } + const current = connectedRef.current; + const currentInfo = lastCandidateRef.current?.info; + if (!current || !currentInfo) return; + const keys = new Set(candidates.map((c) => c.info.key)); + const seen = seenKeysRef.current; + seenKeysRef.current = keys; + // Keep the device list current too, quietly (no loading flicker), so + // "Back" shows what is actually plugged in right now. + setList((prev) => { + if (prev.status !== "loaded") return prev; + const same = prev.candidates.length === candidates.length + && prev.candidates.every((c, i) => c.info.key === candidates[i].info.key); + return same ? prev : { status: "loaded", candidates }; + }); + + const switchTo = async (target: CandidateInterface, expectWired: boolean): Promise => { + switchingRef.current = true; + try { + const device = await connectToInterface(target.info); + // Same mouse? The serial settles it where the driver reports one; + // the product-string match already got us here otherwise. + const sameUnit = device.status.unitId == null || current.status.unitId == null + || device.status.unitId === current.status.unitId; + if (!sameUnit) return false; + if (expectWired && device.status.connectionType !== "Wired") return false; + lastReadAtRef.current = Date.now(); + setConnected(device); + lastCandidateRef.current = target; + rememberDevice(target.info, device.brand); + showToast(`${device.status.name}: now ${device.status.connectionType?.toLowerCase() ?? "connected"}.`, "success"); + return true; + } catch (error) { + if (!isHidBusyError(error)) { + const message = error instanceof Error ? error.message : String(error); + void invoke("log_line", { line: `[transport] switch to ${target.info.key} failed: ${message}` }).catch(() => {}); + } + return false; + } finally { + switchingRef.current = false; + } + }; + + if (!keys.has(current.key)) { + // 1. Gone. Try a sibling; otherwise drop to the list. + const sibling = candidates.find((c) => isSiblingTransport(c.info, currentInfo)); + if (sibling && (await switchTo(sibling, false))) return; + awaitingReturnRef.current = currentInfo; + setAwaitingReturn(true); + setConnected(null); + lastCandidateRef.current = null; + setView("list"); + showToast(`${current.status.name} disconnected. It will reconnect when plugged back in.`, "error"); + return; + } + if (seen && current.status.connectionType !== "Wired") { + // 2. Something new since the last scan that looks like this mouse. + const appeared = candidates.find((c) => !seen.has(c.info.key) && isSiblingTransport(c.info, currentInfo)); + if (appeared) await switchTo(appeared, true); + } + }, TRANSPORT_SCAN_INTERVAL_MS); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connected?.key, awaitingReturn]); + // Mirror the cached device name and battery into the tray menu (tray.rs), // so a right-click on the tray icon shows the charge without bringing the // window back. Keyed on the three fields the menu shows, not on `connected`