diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index e747fe2..ce9a63d 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -7,19 +7,22 @@ const BUDGET_BYTES: Record = { // layer (SVG displacement filters plus their component rules) adds the // largest share. The measured bundle is 153.7 kB; 175 kB adds headroom for // the Developer Hall of Fame page (~15 kB of animated card and hero - // styles that load only on /contributors.html). + // styles that load only on /contributors.html). Raised to 180 kB upstream; + // this also covers the Bridge "Native devices" panel (per-device rows, + // polling control, and the Enable/Remove-driver buttons). ".css": 180_000, // Raised from 510 kB for Bridge discovery, profile editing, automatic // reconnection, and recent device support, which have since grown further // with the supported-device page and MX Master remap controls. Preview // fixtures retain their separate allowance below; the measured aggregate - // is 573.4 kB with them, plus the ~11 kB Hall of Fame chunk. Raised again - // from 590 kB for the Razer button-mapping card and its codec: the measured - // aggregate is 588.2 kB, which left under 2 kB of headroom. Raised again to - // 610 kB for the Pulsar XS-1 feature-report driver and 4K receiver support - // (mouse-protocol 3c3a445): the X3 family codec plus the 4K DPI/polling work - // adds ~1.3 kB to the measured aggregate. - ".js": 610_000, + // is 573.4 kB with them, plus the ~11 kB Hall of Fame chunk. Raised from + // 590 kB for the Razer button-mapping card and its codec, and the Pulsar + // XS-1 feature-report driver plus 4K receiver support (mouse-protocol + // 3c3a445). Raised again for native Bridge device control: the /v1/devices + // client and the "Native devices" panel that lists Bridge-reached mice (e.g. + // the Attack Shark X11, unreachable over WebHID) and drives their DPI and + // polling. Measured aggregate after merging both lines of work. + ".js": 614_000, }; const ASSETS = join("dist", "assets"); diff --git a/src/app/InterfaceSettings.tsx b/src/app/InterfaceSettings.tsx index cb565b0..4879467 100644 --- a/src/app/InterfaceSettings.tsx +++ b/src/app/InterfaceSettings.tsx @@ -1,11 +1,14 @@ import { useCallback, useEffect, useState, type ReactNode } from "react"; import { + bridgeDevices, bridgeGames, bridgeHandshake, bridgeProfiles, bridgeStatus, saveBridgeProfiles, saveBridgeDefaultProfile, + setBridgeDriver, + type BridgeDevice, type BridgeGame, type BridgeProfile, type BridgeStatus, @@ -109,6 +112,8 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): const [bridge, setBridge] = useState(null); const [bridgeProfilesList, setBridgeProfilesList] = useState([]); const [bridgeGamesList, setBridgeGamesList] = useState([]); + const [bridgeDeviceList, setBridgeDeviceList] = useState([]); + const [bridgeDeviceBusy, setBridgeDeviceBusy] = useState(null); const [selectedGameName, setSelectedGameName] = useState(""); const [bridgeConnectionRequested, setBridgeConnectionRequested] = useState(true); const [bridgeChecking, setBridgeChecking] = useState(false); @@ -146,14 +151,21 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): setBridgeChecking(true); try { await bridgeHandshake(signal); - const [status, profiles, games] = await Promise.all([ + const [status, profiles, games, devices] = await Promise.all([ bridgeStatus(signal), bridgeProfiles(signal), bridgeGames(signal), + bridgeDevices(signal).catch(() => [] as BridgeDevice[]), ]); setBridge(status); setBridgeProfilesList(profiles); setBridgeGamesList(games); + // Leave the list untouched while a polling write is settling so the + // control does not flicker back to the pre-write value mid-request. + setBridgeDeviceBusy((busy) => { + if (busy === null) setBridgeDeviceList(devices); + return busy; + }); setSelectedGameName((current) => current || status.activeGames[0] || games[0]?.name || ""); setBridgeMessage(""); } catch { @@ -161,6 +173,7 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): setBridge(null); setBridgeProfilesList([]); setBridgeGamesList([]); + setBridgeDeviceList([]); } } finally { if (!signal?.aborted) setBridgeChecking(false); @@ -190,6 +203,24 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): }; }, [bridgeConnectionRequested, checkBridge]); + const changeDriver = useCallback(async (action: "install" | "uninstall"): Promise => { + setBridgeDeviceBusy(`driver-${action}`); + setBridgeMessage(action === "install" + ? "Approve the Windows prompt to enable native control…" + : "Approve the Windows prompt to remove the driver…"); + try { + await setBridgeDriver(action); + setBridgeMessage(action === "install" + ? "Driver installed. Reconnect the mouse if it does not appear as controllable." + : "Driver removed. The mouse is back to its normal driver."); + await checkBridge(); + } catch (error) { + setBridgeMessage(error instanceof Error ? error.message : "The driver change did not complete."); + } finally { + setBridgeDeviceBusy(null); + } + }, [checkBridge]); + const selectedGame = bridgeGamesList.find((game) => game.name === selectedGameName) ?? null; const status = snapshot.status; const deviceId = status ? `${status.brand}:${status.name}` : ""; @@ -200,8 +231,10 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): application: { name: status.name, executable: "", path: "" }, device: { id: deviceId, name: status.name }, settings: { - dpi: status.dpi ?? null, - pollingRateHz: status.pollingRateHz ?? null, + // 0 means "not readable" (e.g. the browser-read-only Attack Shark X11), + // so store null rather than a bogus 0 DPI / 0 Hz profile. + dpi: status.dpi || null, + pollingRateHz: status.pollingRateHz || null, }, }).catch(() => undefined); }, [bridgeConnected, deviceId, status?.dpi, status?.name, status?.pollingRateHz]); @@ -219,8 +252,10 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): }, device: { id: deviceId, name: status.name }, settings: { - dpi: status.dpi ?? null, - pollingRateHz: status.pollingRateHz ?? null, + // 0 means "not readable" (e.g. the browser-read-only Attack Shark X11), + // so store null rather than a bogus 0 DPI / 0 Hz profile. + dpi: status.dpi || null, + pollingRateHz: status.pollingRateHz || null, }, }; const next = [ @@ -365,6 +400,68 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): ) : null} + {bridge ? ( +
+
+
+ NATIVE DEVICES +

Mice the Bridge reaches directly, bypassing the browser

+
+
+ {bridgeDeviceList.length === 0 ? ( +

+ No Bridge-controlled mice detected. Plug in an Attack Shark X11 — it needs the Bridge because its + settings channel is not reachable from a browser. +

+ ) : ( +
    + {bridgeDeviceList.map((device) => ( +
  • +
    + {device.name} + + {device.connection === "wireless" ? "Wireless" : "Wired"} + {device.batteryPercent !== null ? ` · ${device.batteryPercent}% battery` : ""} + {device.controllable ? "" : " · needs setup"} + +
    + {device.controllable ? ( + + Ready. It appears in the sidebar — select it to change DPI and polling + rate under Overview and Performance, like any other mouse. + + ) : ( + {device.note} + )} + {bridge?.platform === "windows" ? ( +
    + {device.controllable ? ( + + ) : ( + + )} +
    + ) : null} +
  • + ))} +
+ )} +
+ ) : null} {bridge ? (
diff --git a/src/app/Sidebar.tsx b/src/app/Sidebar.tsx index 3eff12a..62c4043 100644 --- a/src/app/Sidebar.tsx +++ b/src/app/Sidebar.tsx @@ -150,7 +150,8 @@ export function Sidebar({ snapshot, onOpenSupportRequests }: { snapshot: Control aria-current={device.selected} onClick={() => { control.closeInterfaceSettings(); - void control.selectAuthorizedDevice(device.index); + if (device.bridgeId) void control.selectBridgeDevice(device.bridgeId); + else void control.selectAuthorizedDevice(device.index); }} > diff --git a/src/bridge.ts b/src/bridge.ts index f0adb1b..9414973 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -60,6 +60,83 @@ export async function bridgeGames(signal?: AbortSignal): Promise { return bridgeRequest("/v1/games", undefined, signal); } +/** + * A mouse the Bridge can reach natively — used for devices whose config + * channel the browser cannot touch (e.g. the Attack Shark X11, whose settings + * live on HID collections Chrome protects). `null` fields mean "not readable". + */ +export interface BridgeDevice { + id: string; + name: string; + vendorId: number; + productId: number; + connection: "wired" | "wireless"; + /** True when the Bridge claimed the control interface and can send commands. */ + controllable: boolean; + batteryPercent: number | null; + pollingRateHz: number | null; + supportedPollingRates: number[]; + /** DPI stages as last written through the Bridge (the mouse doesn't report them). */ + dpiStages: number[]; + /** Active DPI stage, 1-based. */ + activeDpiStage: number; + dpiMin: number; + dpiMax: number; + dpiStep: number; + note: string; +} + +export async function bridgeDevices(signal?: AbortSignal): Promise { + return bridgeRequest("/v1/devices", undefined, signal); +} + +export async function setBridgeDevicePolling(id: string, hz: number): Promise { + const result = await bridgeRequest<{ ok: boolean; pollingRateHz: number }>( + `/v1/devices/${encodeURIComponent(id)}/polling`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hz }), + }, + ); + return result.pollingRateHz; +} + +/** Write the six DPI stages and the active stage (1-based) to a Bridge device. */ +export async function setBridgeDeviceDpi( + id: string, + stages: number[], + activeStage: number, +): Promise { + await bridgeRequest( + `/v1/devices/${encodeURIComponent(id)}/dpi`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stages, activeStage }), + }, + ); +} + +/** + * Install or remove the WinUSB driver package (Windows only) that lets the + * Bridge reach a mouse whose config interface the HID stack blocks — e.g. the + * Attack Shark X11. This shows a Windows UAC prompt, so it is given a long + * timeout to allow for the elevation dialog. + */ +export async function setBridgeDriver(action: "install" | "uninstall"): Promise { + await bridgeRequest( + "/v1/driver", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }, + undefined, + 180_000, + ); +} + export async function saveBridgeProfiles(profiles: BridgeProfile[]): Promise { await bridgeRequest("/v1/profiles", { method: "PUT", @@ -76,14 +153,24 @@ export async function saveBridgeDefaultProfile(profile: BridgeProfile): Promise< }); } -async function bridgeRequest(path: string, init?: RequestInit, signal?: AbortSignal): Promise { - const timeout = AbortSignal.timeout(BRIDGE_TIMEOUT_MS); +async function bridgeRequest( + path: string, + init?: RequestInit, + signal?: AbortSignal, + timeoutMs: number = BRIDGE_TIMEOUT_MS, +): Promise { + const timeout = AbortSignal.timeout(timeoutMs); const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; const response = await fetch(`${BRIDGE_URL}${path}`, { headers: { Accept: "application/json" }, ...init, signal: combined, }); - if (!response.ok) throw new Error(`Bridge returned HTTP ${response.status}.`); + if (!response.ok) { + // The Bridge returns a plain-text reason for 4xx/5xx (e.g. a signing error + // from the driver install); surface it instead of a bare status code. + const detail = await response.text().catch(() => ""); + throw new Error(detail.trim() || `Bridge returned HTTP ${response.status}.`); + } return await response.json() as T; } diff --git a/src/control.css b/src/control.css index 29f5720..927228c 100644 --- a/src/control.css +++ b/src/control.css @@ -817,6 +817,18 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } .openmouse-bridge-action .openmouse-bridge-connect:not(:disabled) { cursor: pointer; opacity: 1; } .openmouse-bridge-action small { color: var(--text-faint); font-size: .54rem; line-height: 1.4; } .openmouse-bridge-applications { grid-column: 1 / -1; display: grid; gap: .65rem; padding-top: 1rem; border-top: 1px solid var(--border); } +.openmouse-bridge-devices { grid-column: 1 / -1; display: grid; gap: .65rem; padding-top: 1rem; border-top: 1px solid var(--border); } +.openmouse-bridge-device-list { display: grid; gap: .55rem; margin: 0; padding: 0; list-style: none; } +.openmouse-bridge-device { display: grid; gap: .4rem; padding: .65rem .75rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-sunken, #0d0e10); } +.openmouse-bridge-device-head { display: flex; align-items: baseline; justify-content: space-between; gap: .75rem; } +.openmouse-bridge-device-head strong { color: var(--text-strong); font-size: .74rem; } +.openmouse-bridge-device-meta { color: var(--faint); font: .55rem var(--font-mono); } +.openmouse-bridge-device-note { color: var(--faint); font-size: .52rem; line-height: 1.4; } +.openmouse-bridge-device-actions { display: flex; gap: .5rem; } +.openmouse-bridge-device-enable { padding: .38rem .7rem; border: 1px solid var(--ui-accent); border-radius: 6px; background: color-mix(in srgb, var(--ui-accent) 16%, transparent); color: var(--text-strong); font-size: .6rem; cursor: pointer; } +.openmouse-bridge-device-enable:disabled { opacity: .55; cursor: default; } +.openmouse-bridge-device-secondary { padding: .34rem .6rem; border: 1px solid var(--border); border-radius: 6px; background: transparent; color: var(--faint); font-size: .56rem; cursor: pointer; } +.openmouse-bridge-device-secondary:disabled { opacity: .55; cursor: default; } .openmouse-arch-udev { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .55rem .75rem; align-items: center; padding: .7rem; border: 1px solid #8a6a38; border-radius: 8px; background: #211b12; } .openmouse-arch-udev strong, .openmouse-arch-udev small { display: block; } .openmouse-arch-udev strong { color: #edca8d; font-size: .68rem; } diff --git a/src/device/controller.ts b/src/device/controller.ts index c6e1a3c..c6490f8 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -8,6 +8,7 @@ import { type PulsarClient, type SupportedClient, } from "../device-clients"; +import { bridgeDevices, setBridgeDeviceDpi, setBridgeDevicePolling, type BridgeDevice } from "../bridge"; import { closestDpiOption } from "../dpi-presets"; import { formatHex, hidTraffic, isMark, markHidActivity, startHidCapture, type HidTrafficEntry } from "../hid-diagnostics"; import { @@ -109,6 +110,7 @@ import { TeevolutionHidClient } from "@openmouse/protocol/drivers/teevolution/hi import { teevolutionProfileForCid } from "@openmouse/protocol/teevolution"; import { VgnF2HidClient } from "@openmouse/protocol/drivers/vgn/hid"; import { KeychronHidClient } from "@openmouse/protocol/drivers/keychron/hid"; +import { AttackSharkHidClient, attackSharkNativeOnlyMessage } from "@openmouse/protocol/drivers/attackshark/hid"; import { FantechHidClient } from "@openmouse/protocol/drivers/fantech/hid"; import { SUPPORTED_HID_FILTERS } from "@openmouse/protocol/drivers/vendors"; import { WLMouseHidClient } from "@openmouse/protocol/drivers/wlmouse/hid"; @@ -165,6 +167,7 @@ const NEEDS_OPEN = [TeevolutionHidClient, VgnF2HidClient, KeychronHidClient, Mod const DEDICATED = [ ...DM_CLASSES, ...RAZER_CLASSES, ...NEEDS_OPEN, EggOp1HidClient, LogitechHidppClient, OrbitalHidClient, RazerViperV4ProHidClient, FinalmouseHidClient, + AttackSharkHidClient, ] as const; const logitechClient = (): LogitechHidppClient | null => activeAs(LogitechHidppClient); @@ -458,8 +461,211 @@ function activeSettingsClient(): SupportedClient | null { return active; } +// ── Attack Shark X11: settings via the Bridge ────────────────────────────── +// The X11 connects over WebHID as a read-only shell; its DPI/polling live on a +// USB interface the browser cannot reach, so the OpenMouse Bridge drives them. +// When the active device is a Bridge-controlled X11 we fold the Bridge's state +// into its status (so the normal DPI/polling cards render) and route those +// setters to the Bridge. Every branch is additive: it only fires for this +// device, so no other mouse's code path changes. + +const ATTACK_SHARK_X11_VID = 0x1d57; +const ATTACK_SHARK_X11_PIDS = new Set([0xfa55, 0xfa60, 0xfa61]); + +/** The Bridge device currently backing the active X11, or null. */ +let activeBridgeX11: BridgeDevice | null = null; + +function bridgeDeviceId(device: HIDDevice): string { + const hex = (value: number): string => value.toString(16).padStart(4, "0"); + return `${hex(device.vendorId)}:${hex(device.productId)}`; +} + +/** + * If the active client is a Bridge-controlled X11, merge the Bridge's DPI, + * polling and battery into `status` and populate `dpiOptions`, so the standard + * cards render. Sets `activeBridgeX11` for setter routing. No-op (and leaves the + * device read-only) when it isn't an X11 or the Bridge can't reach it. + */ +async function applyBridgeX11(client: SupportedClient, status: MouseStatus): Promise { + activeBridgeX11 = null; + if (!(client instanceof AttackSharkHidClient)) return; + const { vendorId, productId } = client.device; + if (vendorId !== ATTACK_SHARK_X11_VID || !ATTACK_SHARK_X11_PIDS.has(productId)) return; + + const id = bridgeDeviceId(client.device); + let device: BridgeDevice | undefined; + try { + device = (await bridgeDevices()).find((entry) => entry.id === id && entry.controllable); + } catch { + return; // Bridge not running — keep the read-only shell. + } + if (!device || device.dpiStages.length === 0) return; + activeBridgeX11 = device; + + const activeStage = Math.max(0, Math.min(device.activeDpiStage - 1, device.dpiStages.length - 1)); + status.dpiStages = device.dpiStages.slice(); + status.activeDpiStage = activeStage; + status.dpi = device.dpiStages[activeStage] ?? status.dpi; + status.pollingRateHz = device.pollingRateHz ?? status.pollingRateHz; + status.supportedPollingRates = device.supportedPollingRates; + status.batteryPercent = device.batteryPercent ?? status.batteryPercent; + status.batteryState = device.batteryPercent != null ? "Discharging" : status.batteryState; + // An empty LOD list hides the sensor card (see cardAvailability). + status.supportedLiftOffDistances = []; + status.ui = { + ...status.ui, + settingsReady: true, + valuesVerified: true, + hideProcessingCard: true, + forceShowBattery: device.connection === "wireless", + statusNote: undefined, + dpiStageEditor: { + maxStages: device.dpiStages.length, + countEditable: false, + minDpi: device.dpiMin, + maxDpi: device.dpiMax, + stepDpi: device.dpiStep, + }, + }; + + // The stage editor validates against dpiOptions, so list every supported step. + const options: number[] = []; + for (let value = device.dpiMin; value <= device.dpiMax; value += device.dpiStep) options.push(value); + dpiOptions = options; +} + +/** Bridge id to route settings to when the active device is a controlled X11. */ +function bridgeX11Target(): string | null { + return activeBridgeX11?.id ?? null; +} + +/** Push the current (staged) DPI stages and active stage to the Bridge. */ +async function writeBridgeX11Dpi(id: string): Promise { + const staged = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null; + const stages = staged?.dpiStages ?? activeBridgeX11?.dpiStages ?? []; + const activeStage = (staged?.activeDpiStage ?? 0) + 1; + await setBridgeDeviceDpi(id, stages, activeStage); + if (activeBridgeX11?.id === id) { + activeBridgeX11 = { ...activeBridgeX11, dpiStages: [...stages], activeDpiStage: activeStage }; + } +} + +// ── Bridge devices as first-class sidebar entries ────────────────────────── +// A Bridge-controlled X11 has no WebHID entry (its interface is bound to +// WinUSB), so it appears in the sidebar straight from the Bridge and activates +// without a browser step. `active`/`activeDevice` stay null; `activeBridgeX11` +// marks the mode and the setters route to the Bridge. + +let bridgeDeviceCache: BridgeDevice[] = []; +let bridgeCacheKey = ""; +let bridgePollTimer: number | null = null; + +/** Build a full MouseStatus from a Bridge device so the normal cards render. */ +function bridgeStatusFor(device: BridgeDevice): MouseStatus { + const activeStage = Math.max(0, Math.min(device.activeDpiStage - 1, device.dpiStages.length - 1)); + return { + brand: "Attack Shark", + name: device.name, + ui: { + family: "attackshark", + settingsReady: true, + valuesVerified: true, + hideProcessingCard: true, + hideUnsupportedPollingRates: true, + forceShowBattery: device.connection === "wireless", + dpiStageEditor: { + maxStages: device.dpiStages.length, + countEditable: false, + minDpi: device.dpiMin, + maxDpi: device.dpiMax, + stepDpi: device.dpiStep, + }, + }, + batteryPercent: device.batteryPercent, + batteryState: device.batteryPercent != null ? "Discharging" : "Unknown", + dpi: device.dpiStages[activeStage] ?? 0, + dpiStages: device.dpiStages.slice(), + activeDpiStage: activeStage, + pollingRateHz: device.pollingRateHz ?? 0, + supportedPollingRates: device.supportedPollingRates, + supportedLiftOffDistances: [], + activeProfile: null, + connectionType: device.connection === "wireless" ? "Wireless" : "Wired", + liftOffDistance: null, + firmware: [], + }; +} + +/** Activate a Bridge device (no WebHID). Renders it in the main control panel. */ +export async function selectBridgeDevice(id: string): Promise { + if (settingInProgress || refreshInProgress) return; + let device: BridgeDevice | undefined; + try { + device = (await bridgeDevices()).find((entry) => entry.id === id && entry.controllable); + } catch { + toastForError("Connection failed", new Error("The OpenMouse Bridge is not reachable.")); + return; + } + if (!device || activeBridgeX11?.id === id) return; + + await active?.close().catch(() => undefined); + active = null; + activeDevice = null; + activeBridgeX11 = device; + clearPendingChanges(); + onboardProfiles = null; + + const options: number[] = []; + for (let value = device.dpiMin; value <= device.dpiMax; value += device.dpiStep) options.push(value); + dpiOptions = options; + + capabilities = readCapabilities(); + deviceStatusText = "Connected"; + applyStatus(bridgeStatusFor(device)); + await refreshSidebar(); + startAutomaticRefresh(); + setConnectionButtons(false, "Add device"); + pushToast("success", `Connected to ${device.name}`); +} + +/** Poll the Bridge so its devices stay listed in the sidebar and live-updated. */ +function startBridgePolling(): void { + if (bridgePollTimer !== null) return; + const poll = async (): Promise => { + let list: BridgeDevice[] = []; + try { + list = (await bridgeDevices()).filter((entry) => entry.controllable); + } catch { + list = []; + } + const key = JSON.stringify( + list.map((d) => [d.id, d.name, d.connection, d.batteryPercent, d.pollingRateHz, d.dpiStages, d.activeDpiStage]), + ); + if (key === bridgeCacheKey) return; + bridgeCacheKey = key; + bridgeDeviceCache = list; + // Keep the active Bridge device's status live (battery, etc.), or drop it + // if it was unplugged. + if (activeBridgeX11) { + const current = list.find((entry) => entry.id === activeBridgeX11!.id); + if (current) { + activeBridgeX11 = current; + const status = bridgeStatusFor(current); + const statusKey = JSON.stringify(status); + if (statusKey !== lastRenderedStatusKey && !hasPendingChanges()) applyStatus(status, statusKey); + } else { + showDisconnectedState(); + emit(); + } + } + void refreshSidebar(); + }; + void poll(); + bridgePollTimer = window.setInterval(() => void poll(), 5_000); +} + function hasActiveClient(): boolean { - return activeSettingsClient() !== null; + return activeSettingsClient() !== null || activeBridgeX11 !== null; } function requireSettingsClient(): SupportedClient { @@ -1220,9 +1426,10 @@ function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void { const summary = deviceStatus.batteryPercent === null ? "Connected" : `Battery ${deviceStatus.batteryPercent}%`; - readStatus = status.ui?.valuesVerified + const base = status.ui?.valuesVerified ? [summary, `${deviceStatus.dpi.toLocaleString()} DPI`, `${deviceStatus.pollingRateHz.toLocaleString()} Hz`].join(" · ") : summary; + readStatus = status.ui?.statusNote ? `${base} · ${status.ui.statusNote}` : base; } else if (!hasPendingChanges()) { readStatus = `Current: ${deviceStatus.dpi.toLocaleString()} DPI · ${deviceStatus.pollingRateHz.toLocaleString()} Hz`; } @@ -1275,7 +1482,17 @@ function sidebarEntries(devices: HIDDevice[]): SidebarDevice[] { async function refreshSidebar(devices?: HIDDevice[]): Promise { const all = devices ?? await navigator.hid?.getDevices() ?? []; - sidebarDevices = sidebarEntries(all); + const webhid = sidebarEntries(all); + // Bridge-only devices (e.g. the WinUSB-bound X11) have no WebHID entry, so add + // them from the cached Bridge list. Negative indices keep them distinct. + const bridge: SidebarDevice[] = bridgeDeviceCache.map((device, index) => ({ + index: -1 - index, + bridgeId: device.id, + name: device.name, + detail: `Attack Shark · ${device.connection === "wireless" ? "Wireless" : "Wired"}`, + selected: activeBridgeX11?.id === device.id, + })); + sidebarDevices = [...webhid, ...bridge]; emit(); } @@ -1339,6 +1556,7 @@ async function activateClientNow(client: SupportedClient): Promise { if (activeDevice !== client.device) onboardProfiles = null; buttons = null; activeDevice = client.device; + activeBridgeX11 = null; // Switching to a WebHID device leaves Bridge mode. recordDiagnosticCommand("Read device status"); lastRenderedStatusKey = null; active = client; @@ -1351,6 +1569,7 @@ async function activateClientNow(client: SupportedClient): Promise { } else { const status = await client.readStatus(); dpiOptions = await client.getDpiOptions(); + await applyBridgeX11(client, status); const dm = dmClient(); const keychron = keychronClient(); if (dm) lastSleepSeconds = status.sleepTimeout ?? dm.getSleepOptions()[0] ?? 60; @@ -1409,6 +1628,7 @@ function showDisconnectedState(): void { } clearActiveClients(); activeDevice = null; + activeBridgeX11 = null; onboardProfiles = null; lastRenderedStatusKey = null; capabilities = null; @@ -1536,6 +1756,10 @@ async function requestSupportedClient(): Promise { + "Synapse app, so this mouse needs a native client.", ); } + // The Attack Shark X11 family has the same problem: its settings channel is + // on browser-protected collections, so no granted entry can ever answer. + const x11Message = attackSharkNativeOnlyMessage(devices); + if (x11Message) throw new Error(await x11MessageForBridge(devices, x11Message)); throw new Error( `Selected device is not a supported control interface (${details}). ` + "Pick a vendor control interface (not a plain boot mouse). " @@ -1543,6 +1767,32 @@ async function requestSupportedClient(): Promise { ); } +/** + * Tailor the X11 "browser can't reach this" message to the Bridge's state: if + * the Bridge already has native control enabled for this exact mouse, point the + * user at the Native devices panel instead of telling them to enable something + * that is already on. Falls back to the setup message when the Bridge is not + * running or does not (yet) control this device. + */ +async function x11MessageForBridge(devices: HIDDevice[], fallback: string): Promise { + try { + const bridgeList = await bridgeDevices(); + const enabled = devices.some((device) => + bridgeList.some((entry) => + entry.controllable + && entry.vendorId === device.vendorId + && entry.productId === device.productId)); + if (enabled) { + return "This Attack Shark X11 already has native control enabled in the OpenMouse Bridge. " + + "Change its DPI, polling rate and lighting in Interface settings → Bridge → Native " + + "devices; the browser view stays read-only for this mouse."; + } + } catch { + // Bridge not running or unreachable — fall through to the setup message. + } + return fallback; +} + export async function connect(): Promise { setConnectionButtons(true, "Connecting…"); deviceStatusText = "Requesting permission"; @@ -1705,6 +1955,8 @@ export function applyDpiValue(dpi: number): boolean { } }, apply: async () => { + const bridgeId = bridgeX11Target(); + if (bridgeId) return writeBridgeX11Dpi(bridgeId); await requireClientMethod("setDpi", "DPI").setDpi(dpi); }, }); @@ -1755,6 +2007,8 @@ export function applyActiveDpiStage(stage: number): void { status.dpi = status.dpiStages?.[stage] ?? status.dpi; }, apply: async () => { + const bridgeId = bridgeX11Target(); + if (bridgeId) return writeBridgeX11Dpi(bridgeId); await requireClientMethod("setActiveDpiStage", "DPI stage").setActiveDpiStage(stage); }, }); @@ -1786,6 +2040,8 @@ export function applyDpiStageValue(stage: number, rawDpi: number): void { if ((status.activeDpiStage ?? 0) === stage) status.dpi = dpi; }, apply: async () => { + const bridgeId = bridgeX11Target(); + if (bridgeId) return writeBridgeX11Dpi(bridgeId); await requireClientMethod("setDpiStageValue", "DPI stage value").setDpiStageValue(stage, dpi); }, }); @@ -2450,6 +2706,14 @@ export function applyPollingRate(rate: number): void { status.pollingRateHz = rate; }, apply: async () => { + const bridgeId = bridgeX11Target(); + if (bridgeId) { + const confirmed = await setBridgeDevicePolling(bridgeId, rate); + if (activeBridgeX11?.id === bridgeId) { + activeBridgeX11 = { ...activeBridgeX11, pollingRateHz: confirmed }; + } + return; + } await requireClientMethod("setPollingRate", "the polling rate").setPollingRate(rate); }, }); @@ -3020,6 +3284,8 @@ function startAutomaticRefresh(): void { } async function refreshStatus(): Promise { + // Bridge devices refresh from the Bridge poll, not a WebHID client. + if (activeBridgeX11) return; const client = activeSettingsClient(); if (!client || refreshInProgress || settingInProgress || activationInProgress) return; if ("pollIntervalMs" in client && Number((client as { pollIntervalMs: number }).pollIntervalMs) <= 0) return; @@ -3028,6 +3294,7 @@ async function refreshStatus(): Promise { try { const dm = dmClient(); const status = dm && client === dm ? await dm.readStatus(true) : await client.readStatus(); + await applyBridgeX11(client, status); const currentClient = activeSettingsClient(); if (client !== currentClient || client.device !== activeDevice) return; const key = JSON.stringify(status); @@ -3241,6 +3508,8 @@ export function start(): void { navigator.hid?.addEventListener("connect", handleHidConnect); navigator.hid?.addEventListener("disconnect", handleHidDisconnect); void reconnectAuthorizedDevice(); + // List Bridge-only devices (e.g. the WinUSB-bound X11) in the sidebar. + startBridgePolling(); } window.addEventListener("beforeunload", (event) => { diff --git a/src/device/types.ts b/src/device/types.ts index e73ce6b..61c57fd 100644 --- a/src/device/types.ts +++ b/src/device/types.ts @@ -51,6 +51,8 @@ export interface SidebarDevice { name: string; detail: string; selected: boolean; + /** Set for devices reached through the Bridge (no WebHID entry), e.g. the X11. */ + bridgeId?: string; } export interface DiagnosticsView {