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
19 changes: 11 additions & 8 deletions build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@ const BUDGET_BYTES: Record<string, number> = {
// 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");
Expand Down
107 changes: 102 additions & 5 deletions src/app/InterfaceSettings.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -109,6 +112,8 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }):
const [bridge, setBridge] = useState<BridgeStatus | null>(null);
const [bridgeProfilesList, setBridgeProfilesList] = useState<BridgeProfile[]>([]);
const [bridgeGamesList, setBridgeGamesList] = useState<BridgeGame[]>([]);
const [bridgeDeviceList, setBridgeDeviceList] = useState<BridgeDevice[]>([]);
const [bridgeDeviceBusy, setBridgeDeviceBusy] = useState<string | null>(null);
const [selectedGameName, setSelectedGameName] = useState("");
const [bridgeConnectionRequested, setBridgeConnectionRequested] = useState(true);
const [bridgeChecking, setBridgeChecking] = useState(false);
Expand Down Expand Up @@ -146,21 +151,29 @@ 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 {
if (!signal?.aborted) {
setBridge(null);
setBridgeProfilesList([]);
setBridgeGamesList([]);
setBridgeDeviceList([]);
}
} finally {
if (!signal?.aborted) setBridgeChecking(false);
Expand Down Expand Up @@ -190,6 +203,24 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }):
};
}, [bridgeConnectionRequested, checkBridge]);

const changeDriver = useCallback(async (action: "install" | "uninstall"): Promise<void> => {
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}` : "";
Expand All @@ -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]);
Expand All @@ -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 = [
Expand Down Expand Up @@ -365,6 +400,68 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }):
</button>
</aside>
) : null}
{bridge ? (
<div className="openmouse-bridge-devices">
<div className="openmouse-bridge-app-heading">
<div>
<span>NATIVE DEVICES</span>
<h4>Mice the Bridge reaches directly, bypassing the browser</h4>
</div>
</div>
{bridgeDeviceList.length === 0 ? (
<p className="openmouse-bridge-message" role="status">
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.
</p>
) : (
<ul className="openmouse-bridge-device-list">
{bridgeDeviceList.map((device) => (
<li key={device.id} className="openmouse-bridge-device" data-controllable={device.controllable}>
<div className="openmouse-bridge-device-head">
<strong>{device.name}</strong>
<span className="openmouse-bridge-device-meta">
{device.connection === "wireless" ? "Wireless" : "Wired"}
{device.batteryPercent !== null ? ` · ${device.batteryPercent}% battery` : ""}
{device.controllable ? "" : " · needs setup"}
</span>
</div>
{device.controllable ? (
<small className="openmouse-bridge-device-note">
Ready. It appears in the sidebar — select it to change DPI and polling
rate under Overview and Performance, like any other mouse.
</small>
) : (
<small className="openmouse-bridge-device-note">{device.note}</small>
)}
{bridge?.platform === "windows" ? (
<div className="openmouse-bridge-device-actions">
{device.controllable ? (
<button
type="button"
className="openmouse-bridge-device-secondary"
disabled={bridgeDeviceBusy !== null}
onClick={() => void changeDriver("uninstall")}
>
Remove driver
</button>
) : (
<button
type="button"
className="openmouse-bridge-device-enable"
disabled={bridgeDeviceBusy !== null}
onClick={() => void changeDriver("install")}
>
{bridgeDeviceBusy === "driver-install" ? "Enabling…" : "Enable native control"}
</button>
)}
</div>
) : null}
</li>
))}
</ul>
)}
</div>
) : null}
{bridge ? (
<div className="openmouse-bridge-applications">
<div className="openmouse-bridge-app-heading">
Expand Down
3 changes: 2 additions & 1 deletion src/app/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}}
>
<span className={`device-dot${device.selected ? "" : " is-idle"}`} />
Expand Down
93 changes: 90 additions & 3 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,83 @@ export async function bridgeGames(signal?: AbortSignal): Promise<BridgeGame[]> {
return bridgeRequest<BridgeGame[]>("/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<BridgeDevice[]> {
return bridgeRequest<BridgeDevice[]>("/v1/devices", undefined, signal);
}

export async function setBridgeDevicePolling(id: string, hz: number): Promise<number> {
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<void> {
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<void> {
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<void> {
await bridgeRequest("/v1/profiles", {
method: "PUT",
Expand All @@ -76,14 +153,24 @@ export async function saveBridgeDefaultProfile(profile: BridgeProfile): Promise<
});
}

async function bridgeRequest<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> {
const timeout = AbortSignal.timeout(BRIDGE_TIMEOUT_MS);
async function bridgeRequest<T>(
path: string,
init?: RequestInit,
signal?: AbortSignal,
timeoutMs: number = BRIDGE_TIMEOUT_MS,
): Promise<T> {
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;
}
12 changes: 12 additions & 0 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading
Loading