From d58c55355c62361dd5665d44cf21dc0c1effac8e Mon Sep 17 00:00:00 2001 From: dingo Date: Mon, 10 Aug 2026 12:58:15 +0700 Subject: [PATCH] Patch B: name the sockets what clients open, and accept dispatch The sockets were created as `.socket` and `.socket2`. Hyprland's are `.socket.sock` and `.socket2.sock`, and every real client hardcodes those names: waybar's hyprland/* modules, hyprctl, eww, ags. None of them fall back and none of them say much when they fail -- waybar reports "Couldn't connect to ... (3)" once at startup and then disables the module, so the whole symptom is a bar with a workspace widget that never appears. The IPC was correct and unreachable, which is the worst of both. With the names fixed the read side is reachable, but a workspace you can see and cannot click is only half a bar. So: a `dispatch` verb, deliberately the smallest one that makes the bar work. `dispatch workspace N` switches, and everything else is refused. That refusal is the point. This socket is the surface any process able to open it can use to drive the compositor, so the parser rejects rather than guesses: `dispatch exec` is not implemented and never will be here, `killactive` is not implemented, and an unparsed request gets no answer rather than being treated as something near it. Which is how the test earned its keep. `dispatch workspace +1` means "one to the right" in Hyprland, and it was coming through as absolute workspace 1 -- because `u32::from_str` accepts a leading `+`. On workspace 1 that looks like it worked. The target is now checked for digits before it is parsed, so every relative form is rejected instead of silently mistranslated. --- src/hypr_ipc/events.rs | 2 +- src/hypr_ipc/mod.rs | 4 +- src/hypr_ipc/query.rs | 231 +++++++++++++++++++++++++++++++++++------ src/hypr_ipc/socket.rs | 28 +++-- src/hypr_ipc/sync.rs | 4 +- 5 files changed, 221 insertions(+), 48 deletions(-) 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);