diff --git a/native-hid/README.md b/native-hid/README.md index abc53cf..45d8c8e 100644 --- a/native-hid/README.md +++ b/native-hid/README.md @@ -39,12 +39,14 @@ for that brand's vendor id(s) and tries each candidate class directly: throw is treated as correct, and `setDpi()`/`setPollingRate()` are called on it. -The Attack Shark X11 family is the exception. Its settings channel is not +The Attack Shark X11 family is the partial exception. Its settings channel is not visible through WebHID, so `apply.mjs` first checks for VID `0x1d57`, PIDs -`0xfa55`/`0xfa60`/`0xfa61`, interface 2 and sends its verified polling feature -report directly through `node-hid`. This keeps the normal operating-system -HID driver in place; WinUSB and Zadig are not required. Native X11 DPI support -is deliberately rejected until its six stages can be read and preserved. +`0xfa55`/`0xfa60`/`0xfa61`, interface 2: polling-only requests are sent as the +verified polling feature report directly through `node-hid`. This keeps the normal operating-system +HID driver in place; WinUSB and Zadig are not required. Requests involving DPI +fall through to the generic `AttackSharkHidClient` path, which drives the 0x04 +six-stage table for model `0x55` (mouse-protocol #174), preserving the stages +the request does not touch. ## Exit code contract (Bridge depends on this — see `src/drivers/native_hid.rs`) @@ -93,7 +95,11 @@ runtime first and falls back to `node` on `PATH`, so local development - Attack Shark X11 polling was hardware-verified on Windows with the wireless PID `0xfa60`, interface 2, using the standard Microsoft HID driver. Switching - between 500 Hz and 1000 Hz succeeded without WinUSB or Zadig. + between 500 Hz and 1000 Hz succeeded without WinUSB or Zadig. DPI goes + through the generic `AttackSharkHidClient` 0x04 path (six-stage table, + model `0x55`); the reporter in mouse-protocol #161 confirmed DPI and + polling controls working after that fix, with a read-back write round-trip + still to be captured. - Other adapter and dispatch paths are structurally tested (module resolution, exit codes and control flow) and still require validation on their respective hardware. diff --git a/native-hid/package-lock.json b/native-hid/package-lock.json index 1c4e773..a51ab21 100644 --- a/native-hid/package-lock.json +++ b/native-hid/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@openmouse/keyboard-protocol": "github:OpenMouse-Project/keyboard-protocol#v1.1.0", - "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.10.0", + "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.42.1", "node-hid": "^3.1.0" }, "engines": { @@ -26,7 +26,8 @@ }, "node_modules/@openmouse/protocol": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#b69cd44b2498dcaaa65fd6f77ba10bf109e49322", + "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#c3b1bc20866fbef3a27b37ad020887607d6dbbf2", + "license": "AGPL-3.0-or-later", "engines": { "node": ">=20" } diff --git a/native-hid/package.json b/native-hid/package.json index 222c01b..9dc17fd 100644 --- a/native-hid/package.json +++ b/native-hid/package.json @@ -11,15 +11,17 @@ "node": ">=20" }, "dependencies": { - "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.10.0", "@openmouse/keyboard-protocol": "github:OpenMouse-Project/keyboard-protocol#v1.1.0", + "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.42.1", "node-hid": "^3.1.0" }, "allowScripts": { "github:OpenMouse-Project/keyboard-protocol#v1.1.0": true, "github:OpenMouse-Project/keyboard-protocol#cae3d7a74e8256a9a3cc5c7fe2311db1425bb977": true, - "github:OpenMouse-Project/mouse-protocol#v0.10.0": true, + "github:OpenMouse-Project/mouse-protocol#v0.42.1": true, "node-hid@3.4.0": true, - "github:OpenMouse-Project/mouse-protocol#b69cd44b2498dcaaa65fd6f77ba10bf109e49322": true + "github:OpenMouse-Project/mouse-protocol#b69cd44b2498dcaaa65fd6f77ba10bf109e49322": true, + "github:OpenMouse-Project/mouse-protocol#de188133bace18b1f75cd7bfd8360f17197222a5": true, + "github:OpenMouse-Project/mouse-protocol#c3b1bc20866fbef3a27b37ad020887607d6dbbf2": true } } diff --git a/native-hid/src/apply.mjs b/native-hid/src/apply.mjs index 66166da..2d591e9 100644 --- a/native-hid/src/apply.mjs +++ b/native-hid/src/apply.mjs @@ -87,11 +87,14 @@ async function main() { process.exit(EXIT_NO_DRIVER); } + // The Attack Shark X11 family's settings channel is not visible through + // WebHID, but it is reachable over native HID. Polling-only requests take + // the verified direct feature-report path below; anything involving DPI + // falls through to the generic driver path, where AttackSharkHidClient + // drives the 0x04 six-stage table (enabled for model 0x55 in + // mouse-protocol #174), preserving the stages the request does not touch. if (normalizedBrand === "attack shark" && x11DeviceInfos().length > 0) { - if (Number.isFinite(input.dpi)) { - throw new Error("Attack Shark X11 native DPI control is not implemented yet; polling-only requests are supported."); - } - if (Number.isFinite(input.pollingRateHz)) { + if (Number.isFinite(input.pollingRateHz) && !Number.isFinite(input.dpi)) { applyX11PollingRate(input.pollingRateHz); process.exit(EXIT_APPLIED); } diff --git a/src/api.rs b/src/api.rs index 94748c0..428235a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -68,6 +68,7 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .route("/v1/native/settings", put(apply_native_settings)) .route("/v1/battery", put(record_battery)) .route("/v1/autostart", put(set_autostart)) + .route("/v1/panel", put(show_panel)) .merge(hid_route) .layer(SetResponseHeaderLayer::if_not_present( axum::http::HeaderName::from_static("access-control-allow-private-network"), @@ -101,6 +102,11 @@ async fn status(State(service): State) -> Json) -> Json { + service.request_show_panel(); + Json(ApiResult { ok: true }) +} + async fn handshake(State(service): State) -> Json { service.record_client_heartbeat().await; Json(ApiResult { ok: true }) diff --git a/src/config.rs b/src/config.rs index 5f87e70..781e2b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,10 @@ pub struct BridgeConfig { /// user who later turns it off keeps it off. #[serde(default)] pub autostart_configured: bool, + /// Whether the tray panel has been shown once. Bridge stays in the tray + /// after that; the first launch opens the panel so a window actually appears. + #[serde(default)] + pub tray_hint_shown: bool, } #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -126,6 +130,7 @@ impl Default for BridgeConfig { default_profile: None, allowed_origins: default_origins(), autostart_configured: false, + tray_hint_shown: false, } } } @@ -302,6 +307,7 @@ mod tests { default_profile: None, allowed_origins: Vec::new(), autostart_configured: false, + tray_hint_shown: false, } .normalized(); assert_eq!(config.battery_threshold_percent, 100); diff --git a/src/desktop.rs b/src/desktop.rs index da508b6..beccfc1 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -230,6 +230,12 @@ enum BridgeCommand { SetAutomaticUpdates(bool), CheckForUpdates, InstallUpdate(UpdateInfo), + AcknowledgeTrayHint, +} + +enum Startup { + Serving { show_panel: bool }, + AlreadyRunning, } enum UpdateEvent { @@ -354,6 +360,7 @@ struct TrayApp { // saved value; it is saved once released. adjusting_volume: bool, tray_rect: Option, + panel_requests: Receiver<()>, update_state: UpdateState, last_error: Option, } @@ -368,12 +375,17 @@ impl TrayApp { commands: tokio_mpsc::UnboundedSender, updates: Receiver, notices: Receiver, + panel_requests: Receiver<()>, waker: &OnceLock, restart_requested: Arc, visible: bool, + acknowledge_tray_hint: bool, ) -> Result { configure_tray_only_application(); let _ = waker.set(context.clone()); + if acknowledge_tray_hint { + let _ = commands.send(BridgeCommand::AcknowledgeTrayHint); + } // Without the overlay, notices fall back to system notifications. let overlay = Overlay::new() .inspect_err(|error| tracing::warn!(%error, "Could not create the overlay")) @@ -435,6 +447,7 @@ impl TrayApp { notification_volume: 60, adjusting_volume: false, tray_rect: None, + panel_requests, update_state: UpdateState::Idle, last_error: None, }) @@ -566,6 +579,10 @@ impl eframe::App for TrayApp { context.send_viewport_cmd(ViewportCommand::Focus); } } + while self.panel_requests.try_recv().is_ok() { + tracing::debug!("opening the tray panel for another Bridge launch"); + self.set_visible(context, true); + } while let Some(action) = self.tray.next_action() { match action { TrayAction::ToggleWindow { rect } => { @@ -990,15 +1007,18 @@ type BackgroundRuntime = ( Receiver, tokio_mpsc::UnboundedSender, Receiver, + Receiver<()>, + bool, ); impl BackgroundServer { - fn start(notices: NoticeSink) -> Result { + fn start(notices: NoticeSink) -> Result> { let (shutdown_tx, shutdown_rx) = oneshot::channel(); let (ready_tx, ready_rx) = mpsc::sync_channel(1); let (snapshot_tx, snapshots) = mpsc::sync_channel(1); let (command_tx, command_rx) = tokio_mpsc::unbounded_channel(); let (update_tx, updates) = mpsc::sync_channel(8); + let (panel_tx, panel_requests) = mpsc::channel(); let thread = thread::Builder::new() .name("openmouse-bridge-runtime".into()) .spawn(move || { @@ -1009,6 +1029,7 @@ impl BackgroundServer { command_rx, update_tx, notices, + panel_tx, ); if let Err(error) = &outcome { let _ = ready_tx.send(Err(format!("{error:#}"))); @@ -1018,7 +1039,12 @@ impl BackgroundServer { .context("could not start the Bridge runtime")?; match ready_rx.recv_timeout(Duration::from_secs(15)) { - Ok(Ok(())) => Ok(( + Ok(Ok(Startup::AlreadyRunning)) => { + let _ = shutdown_tx.send(()); + let _ = thread.join(); + Ok(None) + } + Ok(Ok(Startup::Serving { show_panel })) => Ok(Some(( Self { shutdown: Some(shutdown_tx), thread: Some(thread), @@ -1026,7 +1052,9 @@ impl BackgroundServer { snapshots, command_tx, updates, - )), + panel_requests, + show_panel, + ))), Ok(Err(error)) => { let _ = shutdown_tx.send(()); let _ = thread.join(); @@ -1061,8 +1089,11 @@ pub fn run() -> Result<()> { "no display found (set WAYLAND_DISPLAY or DISPLAY for the tray panel); running headless: HTTP API, HID, and game profiles still work" ); } - let (server, snapshots, commands, updates) = - BackgroundServer::start((notice_tx, Arc::clone(&waker)))?; + let Some((server, snapshots, commands, updates, _panel_requests, _show_panel)) = + BackgroundServer::start((notice_tx, Arc::clone(&waker)))? + else { + return Ok(()); + }; return run_headless(server, snapshots, commands, updates, notices, waker); } // The GUI closure below moves the channels in, so a GL failure cannot @@ -1070,17 +1101,24 @@ pub fn run() -> Result<()> { // display is reachable (checked above), and treat a run_native error // as fatal here. (A broken-GL fallback would need the channels split // before the closure; revisit if Wayland-compositor reports require it.) - let (server, snapshots, commands, updates) = - BackgroundServer::start((notice_tx, Arc::clone(&waker)))?; + let Some((server, snapshots, commands, updates, panel_requests, show_panel)) = + BackgroundServer::start((notice_tx, Arc::clone(&waker)))? + else { + return Ok(()); + }; let restart_requested = Arc::new(AtomicBool::new(false)); let app_restart_requested = Arc::clone(&restart_requested); - let visible = env::var_os("OPENMOUSE_BRIDGE_SHOW_WINDOW").is_some(); + // The panel stays hidden after the first launch. Until then, and whenever + // OPENMOUSE_BRIDGE_SHOW_WINDOW is set, open it: a tray-only start looks + // like nothing happened, and an elevated process's tray icon does not + // show in the normal notification area. + let visible = show_panel || env::var_os("OPENMOUSE_BRIDGE_SHOW_WINDOW").is_some(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT]) .with_decorations(false) .with_resizable(false) - .with_taskbar(false) + .with_taskbar(show_panel) .with_always_on_top() .with_visible(visible), renderer: eframe::Renderer::Glow, @@ -1096,9 +1134,11 @@ pub fn run() -> Result<()> { commands, updates, notices, + panel_requests, &waker, app_restart_requested, visible, + show_panel, )?)) }), ) @@ -1123,13 +1163,38 @@ fn restart_bridge() -> Result<()> { Ok(()) } +async fn activate_running_bridge() -> bool { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + { + Ok(client) => client, + Err(error) => { + tracing::debug!(%error, "could not ask the running Bridge to open its panel"); + return false; + } + }; + match client + .put(format!("http://127.0.0.1:{BRIDGE_PORT}/v1/panel")) + .send() + .await + { + Ok(response) => response.status().is_success(), + Err(error) => { + tracing::debug!(%error, "the running Bridge did not open its panel"); + false + } + } +} + fn run_server( shutdown: oneshot::Receiver<()>, - ready: SyncSender>, + ready: SyncSender>, snapshots: SyncSender, mut commands: tokio_mpsc::UnboundedReceiver, update_events: SyncSender, (notices, waker): NoticeSink, + panel_requests: Sender<()>, ) -> Result<()> { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -1137,10 +1202,12 @@ fn run_server( .context("could not create the Bridge async runtime")?; runtime.block_on(async move { let (bridge_config, path) = config::load_with_catalog().await?; + let show_panel = !bridge_config.tray_hint_shown; let automatic_updates = bridge_config.automatic_updates; let origins = bridge_config.allowed_origins.clone(); let service = BridgeService::new(bridge_config, path.clone()); let notices = std::sync::Mutex::new(notices); + let panel_waker = Arc::clone(&waker); service.on_notice(move |notice| { let sent = notices .lock() @@ -1150,6 +1217,12 @@ fn run_server( context.request_repaint(); } }); + service.on_show_panel(move || { + let _ = panel_requests.send(()); + if let Some(context) = panel_waker.get() { + context.request_repaint(); + } + }); if let Err(error) = service.enable_autostart_once().await { tracing::warn!(%error, "Could not turn on launch at login"); } @@ -1202,16 +1275,33 @@ fn run_server( BridgeCommand::InstallUpdate(update) => { install_update(update, &update_events).await; } + BridgeCommand::AcknowledgeTrayHint => { + if let Err(error) = command_service.acknowledge_tray_hint().await { + tracing::error!(%error, "could not save Bridge settings"); + } + } } } }); let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), BRIDGE_PORT); - let listener = TcpListener::bind(address).await.with_context(|| { - format!("could not bind http://{address}; is Bridge already running?") - })?; + let listener = match TcpListener::bind(address).await { + Ok(listener) => listener, + Err(error) => { + // The first launch still holds 17846. Ask it to open the panel + // instead of exiting with nothing on screen. + if activate_running_bridge().await { + tracing::info!("OpenMouse Bridge is already running; opening its tray panel"); + let _ = ready.send(Ok(Startup::AlreadyRunning)); + return Ok(()); + } + return Err(error).context(format!( + "could not bind http://{address}; is Bridge already running?" + )); + } + }; tracing::info!(%address, config = %path.display(), "OpenMouse Bridge is ready"); - let _ = ready.send(Ok(())); + let _ = ready.send(Ok(Startup::Serving { show_panel })); let outcome = axum::serve(listener, api::router(service, &origins)) .with_graceful_shutdown(async { let _ = shutdown.await; diff --git a/src/main.rs b/src/main.rs index 2743026..4e26615 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,9 +33,12 @@ use tokio::net::TcpListener; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] fn main() { - let _log_guard = init_tracing(); + let log_guard = init_tracing(); if let Err(error) = desktop::run() { tracing::error!(%error, "OpenMouse Bridge failed"); + // `process::exit` skips the log guard's flush, which is how a second + // launch used to die on the taken port without a line in the log. + drop(log_guard); std::process::exit(1); } } diff --git a/src/service.rs b/src/service.rs index 5a21bf2..f3e51ac 100644 --- a/src/service.rs +++ b/src/service.rs @@ -232,6 +232,7 @@ pub struct BridgeService { inner: Arc>, config_path: Arc, notice_listener: Arc>>, + show_panel: Arc>>>, } struct BridgeState { @@ -345,9 +346,42 @@ impl BridgeService { })), config_path: Arc::new(config_path), notice_listener: Arc::default(), + show_panel: Arc::default(), } } + /// Opens the tray panel. A second Bridge launch calls this over localhost + /// when it finds the port already taken. + pub fn on_show_panel(&self, listener: impl Fn() + Send + Sync + 'static) { + *self + .show_panel + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::new(listener)); + } + + pub fn request_show_panel(&self) { + let listener = self + .show_panel + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + if let Some(listener) = listener { + listener(); + } + } + + pub async fn acknowledge_tray_hint(&self) -> Result<()> { + let config = { + let mut state = self.inner.write().await; + if state.config.tray_hint_shown { + return Ok(()); + } + state.config.tray_hint_shown = true; + state.config.clone() + }; + config::save(&self.config_path, &config) + } + /// Sends profile switches and low-battery alerts to `listener` instead of /// showing a platform notification for them. pub fn on_notice(&self, listener: impl Fn(Notice) + Send + Sync + 'static) {