Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
15 changes: 14 additions & 1 deletion build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,24 @@ 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
<<<<<<< HEAD
// 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,
=======
// 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
Expand All @@ -21,6 +33,7 @@ const BUDGET_BYTES: Record<string, number> = {
// adds ~1.3 kB to the measured aggregate. Raised to 632 kB for the Attack
// Shark GearHub (0x25a7) protocol routed to 0x1d57 VID devices (+1.6 kB).
".js": 632_000,
>>>>>>> upstream/dev
};

const ASSETS = join("dist", "assets");
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"preview": "vite preview"
},
"dependencies": {
"@openmouse/protocol": "https://github.com/OpenMouse-Project/mouse-protocol.git",
"@openmouse/protocol": "https://github.com/viix0dev/mouse-protocol.git",
"preact": "^10.29.8"
},
"devDependencies": {
Expand Down
125 changes: 122 additions & 3 deletions src/app/InterfaceSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
bridgeDevices,
bridgeGames,
bridgeHandshake,
bridgeProfiles,
bridgeStatus,
saveBridgeBattery,
saveBridgeProfiles,
saveBridgeDefaultProfile,
<<<<<<< HEAD
setBridgeDriver,
type BridgeDevice,
=======
type BridgeBatteryReading,
>>>>>>> upstream/dev
type BridgeGame,
type BridgeProfile,
type BridgeStatus,
Expand Down Expand Up @@ -117,6 +123,12 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }):
const [bridge, setBridge] = useState<BridgeStatus | null>(null);
const [bridgeProfilesList, setBridgeProfilesList] = useState<BridgeProfile[]>([]);
const [bridgeGamesList, setBridgeGamesList] = useState<BridgeGame[]>([]);
<<<<<<< HEAD
const [bridgeDeviceList, setBridgeDeviceList] = useState<BridgeDevice[]>([]);
const [bridgeDeviceBusy, setBridgeDeviceBusy] = useState<string | null>(null);
const [selectedGameName, setSelectedGameName] = useState("");
=======
>>>>>>> upstream/dev
const [bridgeConnectionRequested, setBridgeConnectionRequested] = useState(true);
const [bridgeChecking, setBridgeChecking] = useState(false);
const [bridgeMessage, setBridgeMessage] = useState("");
Expand Down Expand Up @@ -169,14 +181,25 @@ 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);
<<<<<<< HEAD
// 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 || "");
=======
>>>>>>> upstream/dev
setBridgeMessage("");
// Best-effort: push the current battery so Bridge can show it and warn
// on low charge. A failure here must not mark the Bridge disconnected.
Expand All @@ -193,6 +216,7 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }):
setBridge(null);
setBridgeProfilesList([]);
setBridgeGamesList([]);
setBridgeDeviceList([]);
}
} finally {
if (!signal?.aborted) setBridgeChecking(false);
Expand Down Expand Up @@ -222,6 +246,28 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }):
};
}, [bridgeConnectionRequested, checkBridge]);

<<<<<<< HEAD
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;
=======
>>>>>>> upstream/dev
const status = snapshot.status;
const deviceId = status ? `${status.brand}:${status.name}` : "";
const bridgeConnected = bridge !== null;
Expand All @@ -231,8 +277,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 Down Expand Up @@ -262,7 +310,16 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }):
path: `openmouse-game:${game.name}`,
},
device: { id: deviceId, name: status.name },
<<<<<<< HEAD
settings: {
// 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,
},
=======
settings,
>>>>>>> upstream/dev
};
const next = [
...bridgeProfilesList.filter((entry) =>
Expand Down Expand Up @@ -406,6 +463,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 Down Expand Up @@ -94,14 +171,24 @@ export async function saveBridgeBattery(
}, signal);
}

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 @@ -818,6 +818,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