diff --git a/package.json b/package.json index 47dc2833..9690966b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,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": { diff --git a/src/app/InterfaceSettings.tsx b/src/app/InterfaceSettings.tsx index 37134f3c..6704ed38 100644 --- a/src/app/InterfaceSettings.tsx +++ b/src/app/InterfaceSettings.tsx @@ -1,4 +1,22 @@ -import { useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { + bridgeDevices, + bridgeGames, + bridgeHandshake, + bridgeProfiles, + bridgeStatus, + latestRelease, + saveBridgeBattery, + saveBridgeProfiles, + saveBridgeDefaultProfile, + setBridgeDriver, + type BridgeBatteryReading, + type BridgeDevice, + type BridgeGame, + type BridgeProfile, + type BridgeStatus, + type GitHubRelease, +} from "../bridge"; import * as control from "../device/controller"; import type { ControlSnapshot } from "../device/types"; import type { InterfacePreferences } from "../interface-preferences"; @@ -24,6 +42,8 @@ const THEME_CHOICES: readonly ThemeSwatch[] = [ { name: "Liquid Glass", accent: "#f4c95d", canvas: "#080a0c", surface: "#151a1e" }, ]; +const ARCH_UDEV_COMMAND = `echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="3554", MODE="0666"' | sudo tee /etc/udev/rules.d/99-openmouse.rules && sudo udevadm control --reload-rules && sudo udevadm trigger`; + function SwitchCard({ overline, title, @@ -127,11 +147,162 @@ function ProfileKeyCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const preferences = snapshot.preferences; + 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); + const [bridgeMessage, setBridgeMessage] = useState(""); + const [updateChecking, setUpdateChecking] = useState(false); + const [updateMessage, setUpdateMessage] = useState(""); + const [bridgeRelease, setBridgeRelease] = useState(null); + + const batteryRef = useRef(null); + const mouse = snapshot.status; + batteryRef.current = + mouse && mouse.batteryPercent != null + ? { + deviceId: `${mouse.brand}:${mouse.name}`, + deviceName: mouse.name, + percent: mouse.batteryPercent, + charging: + mouse.batteryState === "Charging" || + mouse.batteryState === "Charging slowly" || + mouse.batteryState === "Almost full", + } + : null; + const set = (key: K) => (value: InterfacePreferences[K]): void => control.setPreference(key, value); + const checkForUpdates = useCallback(async (signal?: AbortSignal): Promise => { + setUpdateChecking(true); + setUpdateMessage(""); + try { + const bridgeUpdate = await latestRelease("OpenMouse-Project/OpenMouse-Bridge", signal); + setBridgeRelease(bridgeUpdate); + setUpdateMessage("Release information is current."); + } catch (error) { + if (!signal?.aborted) { + setUpdateMessage(error instanceof Error ? error.message : "Could not check for updates."); + } + } finally { + if (!signal?.aborted) setUpdateChecking(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + if (sessionStorage.getItem("openmouse-update-check") !== "done") { + sessionStorage.setItem("openmouse-update-check", "done"); + void checkForUpdates(controller.signal); + } + return () => controller.abort(); + }, [checkForUpdates]); + + const checkBridge = useCallback(async (signal?: AbortSignal): Promise => { + setBridgeChecking(true); + try { + await bridgeHandshake(signal); + 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); + setBridgeDeviceBusy((busy) => { + if (busy === null) setBridgeDeviceList(devices); + return busy; + }); + setSelectedGameName((current) => current || status.activeGames[0] || games[0]?.name || ""); + setBridgeMessage(""); + + const battery = batteryRef.current; + if (battery) { + try { + await saveBridgeBattery(battery, signal); + } catch { + /* ignore — battery sync is optional */ + } + } + } catch { + if (!signal?.aborted) { + setBridge(null); + setBridgeProfilesList([]); + setBridgeGamesList([]); + setBridgeDeviceList([]); + } + } finally { + if (!signal?.aborted) setBridgeChecking(false); + } + }, []); + + useEffect(() => { + if (!bridgeConnectionRequested) return; + const controller = new AbortController(); + let reconnecting = false; + const reconnect = (): void => { + if (reconnecting) return; + reconnecting = true; + void checkBridge(controller.signal).finally(() => { + reconnecting = false; + }); + }; + reconnect(); + const heartbeat = window.setInterval(reconnect, 5_000); + window.addEventListener("focus", reconnect); + document.addEventListener("visibilitychange", reconnect); + return () => { + controller.abort(); + window.clearInterval(heartbeat); + window.removeEventListener("focus", reconnect); + document.removeEventListener("visibilitychange", reconnect); + }; + }, [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 status = snapshot.status; + const deviceId = status ? `${status.brand}:${status.name}` : ""; + const bridgeConnected = bridge !== null; + + useEffect(() => { + if (!bridgeConnected || !status) return; + void saveBridgeDefaultProfile({ + application: { name: status.name, executable: "", path: "" }, + device: { id: deviceId, name: status.name }, + settings: { + dpi: status.dpi || null, + pollingRateHz: status.pollingRateHz || null, + }, + }).catch(() => undefined); + }, [bridgeConnected, deviceId, status?.dpi, status?.name, status?.pollingRateHz]); + + const isArchLinux = bridge?.linuxDistribution?.split(/\s+/).includes("arch") ?? false; + return ( - <>
- OPENMOUSE BRIDGE -

Game detection and battery alerts

-

- A lightweight background service that detects when games start and sends battery - notifications for your mice. -

- +
+ OPENMOUSE BRIDGE +

Automatic game detection and battery alerts

+

+ OpenMouse Bridge is a lightweight background service that works with the OpenMouse + control panel to detect when games start and send battery notifications for your mice. +

+
    +
  • Runs quietly in the background
  • +
  • Detects active games automatically
  • +
  • Sends mouse battery notifications
  • +
+
+
+ {bridge ? `VERSION ${bridge.version}` : "IN DEVELOPMENT"} +
+