diff options
Diffstat (limited to 'crates/platform/src/ipc.rs')
| -rw-r--r-- | crates/platform/src/ipc.rs | 504 |
1 files changed, 504 insertions, 0 deletions
diff --git a/crates/platform/src/ipc.rs b/crates/platform/src/ipc.rs new file mode 100644 index 0000000..9b140f9 --- /dev/null +++ b/crates/platform/src/ipc.rs @@ -0,0 +1,504 @@ +//! A tiny local control socket, in the spirit of `hyprctl`/`swaymsg`, so +//! external scripts can query and drive window state without speaking +//! Wayland themselves. Bound at `$XDG_RUNTIME_DIR/srdwm-<display>.sock`. +//! `crates/ctl` (the `srd` binary) is the reference client. +//! +//! Deliberately synchronous and non-blocking-polled from each backend's +//! `poll_events()` tick (see `udev.rs`/`winit.rs`), the same way the +//! Wayland client socket itself is accepted - there is no calloop event +//! loop shared by both backends (`winit.rs` has none at all), so this +//! avoids needing two different registration mechanisms for one feature. +//! An ordinary request is one request/one response/close; nothing here is +//! held open for those, so a stalled or hostile client can only ever leak +//! one never-completed connection object, not block the compositor. +//! +//! `{"cmd":"subscribe"}` is the one exception: instead of closing after its +//! reply, that connection is kept open and pushed a fresh `clients` event +//! every time the window list actually changes, so a dock/panel doesn't +//! have to re-poll `clients` on a timer and diff it itself to notice +//! anything - the single highest-leverage gap found comparing srdwm +//! against sway/i3/Hyprland/bspwm's own IPCs, all of which have an +//! event-subscribe side already. A peer session building an AGS dock hit +//! exactly this wall (see `docs/IMPLEMENTATION_STATUS.md`): with no way to +//! be told about changes, it had to poll `wlr-foreign-toplevel` from a +//! separate Python helper instead of using this socket at all. + +use std::io::{ErrorKind, Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; + +use serde::Serialize; + +use srdwm_core::{GlobalMenu, MenuSource, WindowId, WindowManager}; + +pub struct IpcServer { + listener: UnixListener, + path: PathBuf, + conns: Vec<(UnixStream, Vec<u8>)>, + /// Long-lived connections from `{"cmd":"subscribe"}` - write-only after + /// their initial snapshot, never read from again (a subscriber has no + /// further requests to send; a client wanting both query and push needs + /// two connections, matching Hyprland's separate event socket rather + /// than sway's single multiplexed one, the simpler of the two to keep + /// this connection loop's one-purpose-per-connection shape intact). + subscribers: Vec<UnixStream>, + /// What was last actually sent to subscribers, so a `poll()` tick with + /// no real change (the common case, since this runs every ~16ms) skips + /// serializing and writing anything at all. + last_broadcast: Vec<ClientInfo>, +} + +impl IpcServer { + /// `display_name` is the Wayland socket name (e.g. `wayland-1`) so + /// concurrent nested/test instances - used throughout this project for + /// self-testing - don't collide on one path. + pub fn bind(display_name: &str) -> std::io::Result<Self> { + let dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/tmp")); + Self::bind_in(&dir, display_name) + } + + /// `bind`'s actual logic, parametrized over the runtime directory -- + /// split out so tests can point this at a `tempfile::tempdir()` instead + /// of mutating the process-wide `XDG_RUNTIME_DIR` env var (racy under + /// Rust's default parallel test execution, since every test in this + /// crate shares one process). + fn bind_in(dir: &std::path::Path, display_name: &str) -> std::io::Result<Self> { + let path = dir.join(format!("srdwm-{display_name}.sock")); + // A stale socket left behind by a crashed/killed previous instance + // makes `bind` fail with `AddrInUse` even though nothing is + // listening; a fresh instance always wins over a dead one. + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path)?; + listener.set_nonblocking(true)?; + Ok(Self { listener, path, conns: Vec::new(), subscribers: Vec::new(), last_broadcast: Vec::new() }) + } + + /// Accepts any waiting connections, advances in-progress reads, and + /// pushes a fresh snapshot to every subscriber if the window list + /// actually changed since the last one. Returns `true` if a request + /// mutated window state, so the caller can fold that into its own + /// dirty/`sync()` decision the same as any other event source. + pub fn poll(&mut self, wm: &std::rc::Rc<std::cell::RefCell<WindowManager>>) -> bool { + loop { + match self.listener.accept() { + Ok((stream, _addr)) => { + if stream.set_nonblocking(true).is_ok() { + self.conns.push((stream, Vec::new())); + } + } + Err(e) if e.kind() == ErrorKind::WouldBlock => break, + Err(_) => break, + } + } + + let mut dirty = false; + let mut new_subscribers = Vec::new(); + self.conns.retain_mut(|(stream, buf)| { + let mut chunk = [0u8; 512]; + match stream.read(&mut chunk) { + Ok(0) => return false, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if e.kind() == ErrorKind::WouldBlock => {} + Err(_) => return false, + } + let Some(nl) = buf.iter().position(|&b| b == b'\n') else { + // Cap a request that never terminates - a hostile or + // broken client shouldn't accumulate memory forever. + return buf.len() < 4096; + }; + let line = buf[..nl].to_vec(); + let cmd = serde_json::from_slice::<serde_json::Value>(&line).ok().and_then(|v| v.get("cmd").and_then(|c| c.as_str().map(str::to_string))); + let (response, changed) = handle_request(&line, wm); + dirty |= changed; + let mut out = response; + out.push(b'\n'); + if stream.write_all(&out).is_err() { + return false; + } + if cmd.as_deref() == Some("subscribe") { + // Handed off to `subscribers` below rather than kept here -- + // this connection is done being read from, only ever + // written to from now on. + if let Ok(cloned) = stream.try_clone() { + new_subscribers.push(cloned); + } + return false; + } + // Every other command is still one request/one response/close, + // same as before subscribe existed. + false + }); + self.subscribers.extend(new_subscribers); + + if !self.subscribers.is_empty() { + let current: Vec<ClientInfo> = client_snapshot(wm); + if current != self.last_broadcast { + if let Ok(mut out) = serde_json::to_vec(&ClientsEvent { event: "clients", clients: ¤t }) { + out.push(b'\n'); + self.subscribers.retain_mut(|s| s.write_all(&out).is_ok()); + } + self.last_broadcast = current; + } + } + dirty + } +} + +impl Drop for IpcServer { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[derive(Serialize, Clone, PartialEq)] +struct ClientInfo { + id: u64, + app_id: String, + title: String, + workspace: usize, + focused: bool, + minimized: bool, + visible: bool, + scratchpad: bool, + // Whether the layout placed this window (tiled) or the user positioned + // it directly (floating) - added for an external panel's auto-hide + // logic, which needs to tell a window the layout placed flush against + // its own reserved edge (expected, not an overlap) from one the user + // actually dragged into that space (a real overlap it should react + // to). Geometry alone can't distinguish the two: a tiled window's edge + // sitting exactly at the usable-area boundary looks identical, in x/y/ + // width/height terms, to a floating window a human dragged flush + // against it. + floating: bool, + // Geometry, in the same global logical-pixel space everything else in + // this compositor uses. Added for an external panel's Overview/window- + // switcher, which has no other way to lay out window miniatures to + // scale - neither `zwlr_foreign_toplevel_management_v1` nor + // `ext_foreign_toplevel_list_v1` carries geometry at all, by design of + // those protocols, so this compositor's own IPC is the only place it + // can come from. + x: i32, + y: i32, + width: u32, + height: u32, + // The window's global-menu D-Bus address (bus name + object paths), + // if it has exported one - `null` for the common case of a window + // with no menu at all, which a consumer should treat exactly like a + // missing field: no menu to show, not an error. See `srdwm_core:: + // GlobalMenu`'s own doc comment for why this is an address and never + // the menu's actual content. + global_menu: Option<GlobalMenuInfo>, +} + +#[derive(Serialize, Clone, PartialEq)] +struct GlobalMenuInfo { + bus_name: String, + menu_path: Option<String>, + app_path: Option<String>, + window_path: Option<String>, + // Which export flavour `menu_path` actually came from - "gtk" (a + // real GMenuModel; actions are `app.xxx`/`win.xxx`, resolved against + // `app_path`/`window_path` under those two prefixes) or "unity" (the + // older Ubuntu-era export; actions are `unity.xxx`, all under one + // group at `menu_path` itself). Not cosmetic: a consumer that guesses + // wrong here gets a menu that renders with every item permanently + // insensitive, since it inserted the D-Bus action group under the + // wrong prefix - indistinguishable from a genuinely broken app + // without this field. Some XWayland clients (`appmenu-gtk-module` in + // particular) set both the `_GTK_*` and `_UNITY_OBJECT_PATH` atoms at + // once, so a consumer can't reliably infer this from which paths are + // merely non-null. + source: &'static str, +} + +impl From<&GlobalMenu> for GlobalMenuInfo { + fn from(m: &GlobalMenu) -> Self { + let source = match m.source { + MenuSource::Gtk => "gtk", + MenuSource::Unity => "unity", + }; + Self { bus_name: m.bus_name.clone(), menu_path: m.menu_path.clone(), app_path: m.app_path.clone(), window_path: m.window_path.clone(), source } + } +} + +#[derive(Serialize)] +struct ClientsResponse { + clients: Vec<ClientInfo>, +} + +#[derive(Serialize)] +struct MonitorsResponse { + monitors: Vec<MonitorInfo>, +} + +/// One entry per `srdwm_core::Monitor` - both rects a panel/dock actually +/// needs to answer "does maximize respect my zone" and "does fullscreen +/// ignore it" without reasoning about either indirectly. Requested by an +/// AGS peer session after two separate live-debugging rounds (maximize- +/// past-dock, fullscreen-past-dock) each took several back-and-forth turns +/// that a single read of this would have settled immediately. +#[derive(Serialize)] +struct MonitorInfo { + id: u32, + name: String, + primary: bool, + // The usable area: the output shrunk by any layer-shell exclusive + // zone currently reserved on it (a bar/dock's `set_exclusive_zone`). + // What `toggle_maximize`/new-window placement/tiling all target. + x: i32, + y: i32, + width: u32, + height: u32, + // The output's true full rect, ignoring any exclusive zone - what + // `toggle_fullscreen` targets. Equal to x/y/width/height above when + // nothing on this monitor currently reserves any space at all. + full_x: i32, + full_y: i32, + full_width: u32, + full_height: u32, +} + +/// Pushed to every subscriber (and used as `subscribe`'s own initial +/// reply) instead of `ClientsResponse`'s plain `{"clients": [...]}"` shape, +/// so every line a subscriber ever reads on that connection looks the +/// same - no special-casing the first one. Not used for the one-shot +/// `"clients"` command, whose response shape predates this and stays as-is +/// for existing polling consumers (`crates/ctl`, any external script). +#[derive(Serialize)] +struct ClientsEvent<'a> { + event: &'static str, + clients: &'a [ClientInfo], +} + +/// The same per-window snapshot both `"clients"` and `"subscribe"`/the +/// change-diff in `IpcServer::poll` build - pulled out so the two can +/// never silently drift into reporting different fields. +fn client_snapshot(wm: &std::rc::Rc<std::cell::RefCell<WindowManager>>) -> Vec<ClientInfo> { + let wm = wm.borrow(); + let current = wm.current_workspace(); + let focused = wm.focused_id(); + wm.windows() + .map(|w| ClientInfo { + id: w.id as u64, + app_id: w.app_id.clone(), + title: w.title.clone(), + workspace: w.workspace, + focused: focused == Some(w.id), + minimized: w.minimized, + visible: !w.minimized && w.workspace == current, + scratchpad: w.scratchpad, + floating: w.floating, + x: w.geometry.x, + y: w.geometry.y, + width: w.geometry.width, + height: w.geometry.height, + global_menu: w.global_menu.as_ref().map(GlobalMenuInfo::from), + }) + .collect() +} + +#[derive(Serialize)] +struct OkResponse { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'static str>, +} + +fn ok() -> Vec<u8> { + serde_json::to_vec(&OkResponse { ok: true, error: None }).unwrap_or_default() +} + +fn err(msg: &'static str) -> Vec<u8> { + serde_json::to_vec(&OkResponse { ok: false, error: Some(msg) }).unwrap_or_default() +} + +/// Parses and applies one request line, returning the response body (no +/// trailing newline) and whether it changed window state. +fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<WindowManager>>) -> (Vec<u8>, bool) { + let Ok(req) = serde_json::from_slice::<serde_json::Value>(line) else { + return (err("invalid request"), false); + }; + let cmd = req.get("cmd").and_then(|v| v.as_str()).unwrap_or(""); + let id = req.get("id").and_then(|v| v.as_u64()).map(|v| v as WindowId); + + match cmd { + "clients" => (serde_json::to_vec(&ClientsResponse { clients: client_snapshot(wm) }).unwrap_or_default(), false), + "monitors" => { + let monitors: Vec<MonitorInfo> = wm + .borrow() + .monitors() + .iter() + .map(|m| MonitorInfo { + id: m.id, + name: m.name.clone(), + primary: m.primary, + x: m.geometry.x, + y: m.geometry.y, + width: m.geometry.width, + height: m.geometry.height, + full_x: m.full_geometry.x, + full_y: m.full_geometry.y, + full_width: m.full_geometry.width, + full_height: m.full_geometry.height, + }) + .collect(); + (serde_json::to_vec(&MonitorsResponse { monitors }).unwrap_or_default(), false) + } + // The connection is handed off to `IpcServer::subscribers` by the + // caller (`poll`, which is the only place that can see the raw + // `cmd` string this deep call already consumed) right after this + // reply is written - this arm only has to produce that reply, in + // the same `ClientsEvent` shape every later push uses. + "subscribe" => { + let clients = client_snapshot(wm); + (serde_json::to_vec(&ClientsEvent { event: "clients", clients: &clients }).unwrap_or_default(), false) + } + "toggle_visibility" => { + let Some(id) = id else { return (err("missing id"), false) }; + let mut wm = wm.borrow_mut(); + let current = wm.current_workspace(); + let Some(w) = wm.windows().find(|w| w.id == id) else { + return (err("no such window"), false); + }; + let now_hidden = w.minimized || w.workspace != current; + if now_hidden { + // Follows the caller to whichever workspace is current -- + // matches Hyprland's `special:scratchpad`/Sway's `scratchpad + // show`, which is the behaviour the `scratchpad` script and + // its keybindings are written against. + wm.move_window_to_workspace(id, current); + wm.restore_window(id); + wm.focus_window(id); + } else { + wm.minimize_window(id); + } + (ok(), true) + } + "focus" => { + let Some(id) = id else { return (err("missing id"), false) }; + wm.borrow_mut().focus_window(id); + (ok(), true) + } + "close" => { + let Some(id) = id else { return (err("missing id"), false) }; + wm.borrow_mut().close_window(id); + (ok(), true) + } + _ => (err("unknown command"), false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::io::BufRead; + use std::rc::Rc; + + fn read_line(stream: &mut UnixStream) -> String { + // The server side is set non-blocking, but the client-side handle a + // test holds is left in its default blocking mode - a plain + // `read_line` on it can simply wait for the byte that's about to + // arrive, no polling loop needed here. + let mut reader = std::io::BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + line + } + + #[test] + fn subscribe_gets_an_immediate_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); + server.poll(&wm); + + let line = read_line(&mut client); + assert!(line.contains(r#""event":"clients""#)); + assert!(line.contains(r#""clients":[]"#)); + } + + #[test] + fn subscribe_then_a_window_change_pushes_a_fresh_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); + server.poll(&wm); + let _initial = read_line(&mut client); + + { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "hello")); + } + server.poll(&wm); + + let pushed = read_line(&mut client); + assert!(pushed.contains(r#""event":"clients""#)); + assert!(pushed.contains(r#""title":"hello""#)); + } + + #[test] + fn a_poll_with_no_real_change_pushes_nothing_new() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); + server.poll(&wm); + let _initial = read_line(&mut client); + + // Nothing changed between these two polls - a second push would + // show up as a second readable line the client isn't expecting. + server.poll(&wm); + server.poll(&wm); + client.set_nonblocking(true).unwrap(); + let mut buf = [0u8; 16]; + match client.read(&mut buf) { + Err(e) if e.kind() == ErrorKind::WouldBlock => {} + other => panic!("expected no further data, got {other:?}"), + } + } + + #[test] + fn a_oneshot_clients_request_still_closes_the_connection_as_before() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"clients\"}\n").unwrap(); + server.poll(&wm); + let line = read_line(&mut client); + // The plain, pre-existing shape - no `"event"` field - so + // existing one-shot polling consumers (`crates/ctl`) see no change. + assert!(!line.contains(r#""event""#)); + assert!(line.contains(r#""clients""#)); + + { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "later")); + } + server.poll(&wm); + client.set_nonblocking(true).unwrap(); + let mut buf = [0u8; 16]; + let result = client.read(&mut buf); + // A one-shot connection was never registered as a subscriber, so a + // later change must not be pushed to it - and the server already + // closed its end after the single reply, so a read either sees EOF + // (0 bytes) or, depending on how quickly the close propagates, + // WouldBlock; either is correct, actual new data would not be. + match result { + Ok(n) => assert_eq!(n, 0, "expected EOF, got {n} bytes of unexpected data"), + Err(e) => assert_eq!(e.kind(), ErrorKind::WouldBlock), + } + } +} |