diff --git a/src/hypr_ipc/events.rs b/src/hypr_ipc/events.rs index 1c2519d..9c5af8b 100644 --- a/src/hypr_ipc/events.rs +++ b/src/hypr_ipc/events.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-only -//! Formatters for Hyprland's `.socket2` event-stream lines. +//! Formatters for Hyprland's `.socket2.sock` event-stream lines. //! //! Every event Hyprland emits is a single line of the form //! `EVENTNAME>>data1,data2,...\n`. Shapes below are taken from diff --git a/src/hypr_ipc/mod.rs b/src/hypr_ipc/mod.rs index 0a20c63..e45b796 100644 --- a/src/hypr_ipc/mod.rs +++ b/src/hypr_ipc/mod.rs @@ -3,7 +3,7 @@ //! Hyprland-compatible IPC socket. //! //! Implements enough of Hyprland's own two-socket IPC protocol -//! (`.socket` for request/response, `.socket2` for a broadcast event +//! (`.socket.sock` for request/response, `.socket2.sock` for a broadcast event //! stream, both under `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/`) //! for unmodified Hyprland IPC clients — most notably waybar's //! `hyprland/workspaces` and `hyprland/window` modules — to work against @@ -14,7 +14,7 @@ //! (unapplied) snippets needed to wire it in. //! //! - [`protocol`]: JSON response DTOs matching `hyprctl -j` shapes. -//! - [`events`]: `.socket2` event-line formatters. +//! - [`events`]: `.socket2.sock` event-line formatters. //! - [`query`]: read-only data gathering against `Common`/`Shell`, plus //! request parsing/dispatch. //! - [`socket`]: calloop wiring (listeners, per-connection sources, diff --git a/src/hypr_ipc/query.rs b/src/hypr_ipc/query.rs index 9cc44f7..debb528 100644 --- a/src/hypr_ipc/query.rs +++ b/src/hypr_ipc/query.rs @@ -1,14 +1,25 @@ // SPDX-License-Identifier: GPL-3.0-only -//! Read-only queries against cosmic-comp's compositor state (`Common`/`Shell`) -//! that gather the data needed to answer Hyprland IPC requests. +//! Queries against cosmic-comp's compositor state (`Common`/`Shell`) that +//! gather the data needed to answer Hyprland IPC requests, plus the small +//! write side needed to make a bar's workspace buttons clickable. //! -//! All functions here are read-only with one exception: `window_address` +//! The read and write halves are kept apart by their signatures: +//! [`handle_query`] takes `&Common` and so cannot mutate anything, while +//! [`handle_dispatch`] takes `&mut State`. Adding a new query therefore +//! cannot accidentally acquire the ability to change compositor state. +//! +//! The gathering functions are read-only with one exception: `window_address` //! lazily mints a stable per-window pseudo-address the first time a window //! is seen and caches it in the window's own `UserDataMap`. That is purely //! additive bookkeeping (via public smithay API) and does not touch any //! existing cosmic-comp state. //! +//! Dispatch changes *runtime* state (which workspace is focused), never +//! configuration, so it does not cut across HyprCosmic's "the file wins, +//! one-way" rule: nothing here writes to cosmic-config, and `cosmic.conf` +//! remains the only source of truth for settings. +//! //! We deliberately query the `Shell` layer (`Common.shell`) rather than the //! lower-level `wayland::protocols::workspace`/`toplevel_info` protocol //! state: those protocol-state types expose no public enumeration API @@ -19,15 +30,18 @@ use std::sync::atomic::{AtomicU64, Ordering}; use smithay::{reexports::wayland_server::Resource, wayland::seat::WaylandFocus}; -use tracing::error; +use tracing::{error, warn}; use crate::{ hypr_ipc::{ events, protocol::{ClientInfo, MonitorInfo, WorkspaceInfo, WorkspaceRef, synth_workspace_id}, }, - shell::{CosmicMapped, CosmicSurface, SeatExt, Shell, Workspace, focus::target::KeyboardFocusTarget}, - state::Common, + shell::{ + CosmicMapped, CosmicSurface, SeatExt, Shell, Workspace, WorkspaceDelta, + focus::target::KeyboardFocusTarget, + }, + state::{Common, State}, utils::prelude::OutputExt, }; @@ -275,6 +289,13 @@ pub fn active_window(shell: &Shell, common: &Common) -> Option { /// are out of scope since waybar's Hyprland modules always request JSON. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Request { + Query(Query), + Dispatch(Dispatch), +} + +/// The read-only endpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Query { Workspaces, ActiveWorkspace, ActiveWindow, @@ -282,40 +303,98 @@ pub enum Request { Monitors, } -/// Parse a raw request line/command as sent on `.socket`, e.g. `workspaces` +/// The write endpoints. Deliberately a short list: this is the surface any +/// process able to open the socket can use to drive the compositor, so it +/// grows only for things a bar genuinely needs, and each variant carries +/// already-validated data rather than a free-form command string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dispatch { + /// Focus a workspace, numbered from 1 as Hyprland numbers them. + Workspace(u32), +} + +/// Parse a raw request line/command as sent on `.socket.sock`, e.g. `workspaces` /// or `j/workspaces` (the `j/` prefix `hyprctl -j` uses to ask for JSON). pub fn parse_request(raw: &str) -> Option { let trimmed = raw.trim(); let trimmed = trimmed.strip_prefix("j/").unwrap_or(trimmed); - match trimmed { - "workspaces" => Some(Request::Workspaces), - "activeworkspace" => Some(Request::ActiveWorkspace), - "activewindow" => Some(Request::ActiveWindow), - "clients" => Some(Request::Clients), - "monitors" => Some(Request::Monitors), + + if let Some(args) = trimmed.strip_prefix("dispatch ") { + return parse_dispatch(args.trim()).map(Request::Dispatch); + } + + let query = match trimmed { + "workspaces" => Query::Workspaces, + "activeworkspace" => Query::ActiveWorkspace, + "activewindow" => Query::ActiveWindow, + "clients" => Query::Clients, + "monitors" => Query::Monitors, + _ => return None, + }; + Some(Request::Query(query)) +} + +/// Parse the argument list of a `dispatch` request. +/// +/// Unknown dispatchers return `None` rather than being accepted and ignored: +/// a bar that asks for something we cannot do should see it fail in the log, +/// not silently appear to work. +fn parse_dispatch(args: &str) -> Option { + let (verb, target) = args.split_once(char::is_whitespace)?; + match verb { + // Hyprland separates these two only where a workspace could be pulled + // onto a different monitor. COSMIC binds every workspace to one output + // already, so both spellings mean the same thing here -- and waybar + // sends whichever of them its config selected. + "workspace" | "focusworkspaceoncurrentmonitor" => { + // `name:foo` is Hyprland's explicit by-name form. COSMIC names + // workspaces by their number, so the two forms coincide, and a + // non-numeric name refers to no workspace we have. + let target = target.trim(); + let number = target.strip_prefix("name:").unwrap_or(target); + // Digits only, checked before parsing rather than relying on it: + // `u32::from_str` accepts a leading `+`, so Hyprland's *relative* + // `workspace +1` ("one to the right") would otherwise come through + // as the *absolute* workspace 1. That is the dangerous kind of + // wrong -- on workspace 1 it looks like it worked. The other + // relative forms (`e+1`, `previous`, `empty`) fail to parse + // anyway, but they are rejected here for the same reason. + if number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + number.parse::().ok().filter(|n| *n > 0).map(Dispatch::Workspace) + } _ => None, } } -/// Build the JSON response body for a parsed request. +/// Answer a parsed request, routing to the read or write half. +pub fn handle_request(request: Request, state: &mut State) -> String { + match request { + Request::Query(query) => handle_query(query, &state.common), + Request::Dispatch(dispatch) => handle_dispatch(dispatch, state), + } +} + +/// Build the JSON response body for a read request. /// /// `activeworkspace`/`activewindow` fall back to `{}` (rather than `null`) /// when there is nothing to report, matching Hyprland's own behavior of /// always returning an object for those two endpoints. -pub fn handle_request(request: Request, common: &Common) -> String { +pub fn handle_query(query: Query, common: &Common) -> String { let shell = common.shell.read(); - let result = match request { - Request::Workspaces => serde_json::to_string(&collect_workspaces(&shell)), - Request::ActiveWorkspace => match active_workspace(&shell) { + let result = match query { + Query::Workspaces => serde_json::to_string(&collect_workspaces(&shell)), + Query::ActiveWorkspace => match active_workspace(&shell) { Some(info) => serde_json::to_string(&info), None => Ok("{}".to_string()), }, - Request::ActiveWindow => match active_window(&shell, common) { + Query::ActiveWindow => match active_window(&shell, common) { Some(info) => serde_json::to_string(&info), None => Ok("{}".to_string()), }, - Request::Clients => serde_json::to_string(&collect_clients(&shell, common)), - Request::Monitors => serde_json::to_string(&collect_monitors(&shell)), + Query::Clients => serde_json::to_string(&collect_clients(&shell, common)), + Query::Monitors => serde_json::to_string(&collect_monitors(&shell)), }; result.unwrap_or_else(|err| { error!(?err, "Failed to serialize hypr_ipc response"); @@ -323,30 +402,114 @@ pub fn handle_request(request: Request, common: &Common) -> String { }) } +/// Carry out a write request. Replies `ok` like Hyprland does, so a client +/// that checks the response sees the same string it would there. +/// +/// Goes through `Shell::activate` -- the identical call the `Workspace` +/// keyboard shortcut makes in `input::actions` -- rather than reaching into +/// the workspace set directly, so an IPC-driven switch and a keyboard-driven +/// one cannot drift apart. +pub fn handle_dispatch(dispatch: Dispatch, state: &mut State) -> String { + match dispatch { + Dispatch::Workspace(number) => { + let output = state.common.shell.read().seats.last_active().active_output(); + // Hyprland numbers workspaces from 1; `activate` indexes from 0. + // The `> 0` filter in the parser is what makes this subtraction + // safe. + let idx = number as usize - 1; + let result = state.common.shell.write().activate( + &output, + idx, + WorkspaceDelta::new_shortcut(), + &mut state.common.workspace_state.update(), + ); + match result { + Ok(_) => "ok".to_string(), + // Asking for a workspace that does not exist is a normal + // thing for a bar configured with more buttons than this + // compositor has workspaces, so report it and carry on. + Err(err) => { + warn!(?err, number, "hypr_ipc dispatch: no such workspace"); + format!("no such workspace: {number}") + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; + fn query(raw: &str) -> Option { + match parse_request(raw) { + Some(Request::Query(q)) => Some(q), + _ => None, + } + } + + fn dispatch(raw: &str) -> Option { + match parse_request(raw) { + Some(Request::Dispatch(d)) => Some(d), + _ => None, + } + } + #[test] fn parses_plain_and_json_prefixed_commands() { - assert_eq!(parse_request("workspaces"), Some(Request::Workspaces)); - assert_eq!(parse_request("j/workspaces"), Some(Request::Workspaces)); - assert_eq!( - parse_request("activeworkspace\n"), - Some(Request::ActiveWorkspace) - ); - assert_eq!( - parse_request("j/activewindow"), - Some(Request::ActiveWindow) - ); - assert_eq!(parse_request(" clients "), Some(Request::Clients)); - assert_eq!(parse_request("j/monitors"), Some(Request::Monitors)); + assert_eq!(query("workspaces"), Some(Query::Workspaces)); + assert_eq!(query("j/workspaces"), Some(Query::Workspaces)); + assert_eq!(query("activeworkspace\n"), Some(Query::ActiveWorkspace)); + assert_eq!(query("j/activewindow"), Some(Query::ActiveWindow)); + assert_eq!(query(" clients "), Some(Query::Clients)); + assert_eq!(query("j/monitors"), Some(Query::Monitors)); } #[test] fn rejects_unknown_commands() { - assert_eq!(parse_request("dispatch workspace 2"), None); assert_eq!(parse_request(""), None); assert_eq!(parse_request("j/"), None); + assert_eq!(parse_request("dispatch"), None); + assert_eq!(parse_request("dispatch "), None); + } + + /// The spellings waybar actually emits. Both of its workspace dispatchers + /// and both of its argument forms have to land on the same workspace, or + /// clicking a workspace button silently does nothing. + #[test] + fn parses_the_workspace_dispatchers_waybar_sends() { + for raw in [ + "dispatch workspace 2", + "dispatch workspace name:2", + "dispatch focusworkspaceoncurrentmonitor 2", + "dispatch focusworkspaceoncurrentmonitor name:2", + "j/dispatch workspace 2", + ] { + assert_eq!(dispatch(raw), Some(Dispatch::Workspace(2)), "{raw}"); + } + } + + /// Anything we cannot map must be rejected rather than accepted and + /// dropped, so that an unsupported dispatcher shows up in the log instead + /// of looking like a compositor that ignores its bar. + #[test] + fn rejects_dispatchers_and_targets_it_cannot_honour() { + for raw in [ + // Relative and named targets COSMIC has no equivalent for. + "dispatch workspace e+1", + "dispatch workspace +1", + "dispatch workspace previous", + "dispatch workspace name:scratch", + // Hyprland numbers workspaces from 1, so 0 addresses nothing -- + // and `handle_dispatch` subtracts 1, so letting it through would + // underflow. + "dispatch workspace 0", + // Dispatchers we have not implemented, including destructive ones. + "dispatch killactive", + "dispatch exec rofi", + "dispatch togglespecialworkspace", + ] { + assert_eq!(parse_request(raw), None, "{raw}"); + } } } diff --git a/src/hypr_ipc/socket.rs b/src/hypr_ipc/socket.rs index 189bd6f..7c9d298 100644 --- a/src/hypr_ipc/socket.rs +++ b/src/hypr_ipc/socket.rs @@ -17,7 +17,7 @@ //! 3. `src/session.rs`'s `get_env` forwards `HYPRLAND_INSTANCE_SIGNATURE` to //! children, the same way `WAYLAND_DISPLAY` is — that is how clients locate //! these sockets. -//! 4. The `.socket2` event stream is driven by [`super::sync::refresh`] from +//! 4. The `.socket2.sock` event stream is driven by [`super::sync::refresh`] from //! `lib.rs`'s throttled `refresh`, which diffs a state snapshot rather than //! emitting from scattered call sites. @@ -41,7 +41,7 @@ use crate::{ state::State, }; -/// A single accepted connection on the request (`.socket`) listener. +/// A single accepted connection on the request (`.socket.sock`) listener. struct RequestStream(UnixStream); impl AsFd for RequestStream { fn as_fd(&self) -> BorrowedFd<'_> { @@ -60,7 +60,7 @@ pub struct HyprIpcHandle { } impl HyprIpcHandle { - /// Whether anyone is listening on `.socket2`. + /// Whether anyone is listening on `.socket2.sock`. /// /// With no bar running this is false on every tick, which lets the /// diffing refresh skip walking the window tree entirely. @@ -115,7 +115,7 @@ impl HyprIpcHandle { let mut streams = self.event_streams.borrow_mut(); // Best-effort broadcast: a write failure (client gone, buffer full // and non-blocking, ...) just drops that subscriber, matching how - // Hyprland itself treats a slow/dead `.socket2` client. + // Hyprland itself treats a slow/dead `.socket2.sock` client. streams.retain_mut(|stream| stream.write_all(line.as_bytes()).is_ok()); } @@ -185,7 +185,7 @@ fn register_request_connection(handle: &LoopHandle<'static, State>, stream: Unix let request = String::from_utf8_lossy(&buf[..n]); match query::parse_request(&request) { Some(req) => { - let response = query::handle_request(req, &state.common); + let response = query::handle_request(req, state); let _ = conn.0.write_all(response.as_bytes()); } None => { @@ -208,7 +208,7 @@ fn register_request_connection(handle: &LoopHandle<'static, State>, stream: Unix .with_context(|| "Failed to register hypr_ipc request connection") } -/// Accept loop for the request (`.socket`) listener. +/// Accept loop for the request (`.socket.sock`) listener. fn register_request_listener(handle: &LoopHandle<'static, State>, listener: UnixListener) -> Result<()> { listener .set_nonblocking(true) @@ -241,7 +241,7 @@ fn register_request_listener(handle: &LoopHandle<'static, State>, listener: Unix .with_context(|| "Failed to register hypr_ipc request listener") } -/// Accept loop for the event (`.socket2`) listener: accepted connections are +/// Accept loop for the event (`.socket2.sock`) listener: accepted connections are /// simply stashed for [`HyprIpcHandle::broadcast_line`] to write to; they /// don't need their own calloop source since cosmic-comp never reads from /// them (a dead/closed connection is detected lazily on the next broadcast @@ -298,8 +298,18 @@ pub fn init(handle: &LoopHandle<'static, State>) -> Result { let dir = socket_dir(&signature).with_context(|| "Failed to create hypr_ipc socket dir")?; - let request_path = dir.join(".socket"); - let event_path = dir.join(".socket2"); + // The `.sock` suffix is not decoration: it is the name every Hyprland + // client hardcodes. waybar's Hyprland backend, hyprctl, eww and ags all + // open `$XDG_RUNTIME_DIR/hypr/$HIS/.socket.sock` and give up if it is + // absent -- waybar reports `Couldn't connect to .../.socket.sock` and + // disables the module, which is the whole difference between an IPC we + // can talk to and one the Hyprland ecosystem can talk to. + // + // No compatibility symlink for the unsuffixed names: those were this + // fork's own mistake and never existed upstream, so nothing but our own + // early probe scripts ever looked for them. + let request_path = dir.join(".socket.sock"); + let event_path = dir.join(".socket2.sock"); // Stale sockets from a previous run with the same (astronomically // unlikely to collide) signature would otherwise make `bind` fail. let _ = std::fs::remove_file(&request_path); diff --git a/src/hypr_ipc/sync.rs b/src/hypr_ipc/sync.rs index a77f706..bd330c0 100644 --- a/src/hypr_ipc/sync.rs +++ b/src/hypr_ipc/sync.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-only -//! Drives the `.socket2` event stream by diffing compositor state. +//! Drives the `.socket2.sock` event stream by diffing compositor state. //! //! Hyprland emits its events from the call sites that cause them. Doing the //! same here would mean instrumenting window map/unmap and every focus-change @@ -149,7 +149,7 @@ mod tests { } /// `emit_diff` drives a real handle, so the tests capture what a subscriber - /// would actually receive over `.socket2`. + /// would actually receive over `.socket2.sock`. fn lines_for(previous: &Snapshot, current: &Snapshot) -> Vec { let (ipc, rx) = super::super::socket::HyprIpcHandle::for_test(); emit_diff(&ipc, previous, current);