From 331a1a1d3a0d8dfd4c285b755c2e13785c95ee2a Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 5 Dec 2024 01:28:00 +0200 Subject: Accumulate platform/ctl-crate additions: capture workspace, IPC responses, CLI verbs Bundles related IPC-surface and CLI additions built up over this session: - capture_workspace IPC command + srd capture workspace CLI verb (see the wayland-crate commit for the off-screen render this drives). - Expanded IPC response payloads (monitors, client events, global menu info) and their matching CLI plumbing. Same reasoning as the core-crate sweep commit for why this is coarser than the repo's usual convention: too entangled to split safely without a dedicated review pass. --- crates/ctl/src/main.rs | 330 ++++++++++++++++++- crates/platform/Cargo.toml | 22 ++ crates/platform/src/ipc.rs | 788 +++++++++++++++++++++++++++++++++++++++++++-- crates/platform/src/lib.rs | 26 ++ 4 files changed, 1115 insertions(+), 51 deletions(-) (limited to 'crates') diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs index a82fe4a..a22477c 100644 --- a/crates/ctl/src/main.rs +++ b/crates/ctl/src/main.rs @@ -6,14 +6,40 @@ //! //! Usage: //! srd clients list windows, one JSON object +//! srd workspaces list workspaces, one JSON object +//! srd keyboard layout active XKB layout name, one JSON object //! srd subscribe like `clients`, then one JSON //! object per line forever, each time -//! the window list actually changes -//! srd dispatch toggle_visibility ID hide/show a window +//! the window list, workspace list, or +//! keyboard layout actually changes +//! (one "clients"/"workspaces"/ +//! "keyboard_layout"-tagged line per +//! event) +//! +//! `dispatch` reads as a real verb phrase, not a joined identifier -- +//! `toggle floating`, not `toggle_floating`. Multi-word actions are +//! genuinely separate arguments (ordinary shell word-splitting), matched +//! here on the words that follow the leading verb rather than one fused +//! snake_case token: //! srd dispatch focus ID //! srd dispatch close ID -//! srd dispatch toggle_maximize ID -//! srd dispatch toggle_fullscreen ID +//! srd dispatch toggle visibility ID hide/show a window +//! srd dispatch toggle maximize ID +//! srd dispatch toggle fullscreen ID +//! srd dispatch toggle floating ID +//! srd dispatch toggle pinned ID +//! srd dispatch move window ID left|right|up|down +//! srd dispatch move workspace ID WORKSPACE +//! srd dispatch activate workspace ID +//! srd dispatch cycle keyboard layout no ID - there's only ever one keyboard +//! srd set border_width 3 live theme values, applied immediately +//! srd set border_color '#cba6f7' (hex string) +//! srd set corner_radius 10 +//! srd set gap_inner 8 +//! srd set gap_outer 16 +//! srd set shadows true +//! srd set rounded_corners true +//! srd set decoration_mode server|client //! //! The socket is Unix-domain; on platforms without one this always fails //! cleanly rather than not building at all, since it's one binary in a @@ -31,9 +57,40 @@ fn main() { } }; - let result = if args.first().map(String::as_str) == Some("subscribe") { unix::stream(&request) } else { unix::send(&request).map(|r| println!("{r}")) }; - if let Err(e) = result { - eprintln!("srd: {e}"); + if args.first().map(String::as_str) == Some("subscribe") { + if let Err(e) = unix::stream(&request) { + eprintln!("srd: {e}"); + std::process::exit(1); + } + return; + } + + let response = match unix::send(&request) { + Ok(r) => r, + Err(e) => { + eprintln!("srd: {e}"); + std::process::exit(1); + } + }; + println!("{response}"); + // A rejected `dispatch`/`set` (a bad key, a malformed value, a window + // id that doesn't exist) is still a *successful* request/response -- + // `unix::send` returns `Ok` either way, since nothing went wrong at + // the transport level - so a caller checking only the exit code, not + // the JSON body, saw every rejection as a success. Reported live by an + // AGS peer session: it memoizes the last value sent per `srd set` key + // to avoid re-spawning a process on every option change, and a + // rejected write counted as a success would have pinned that setting + // permanently. `{"ok":false,...}` is the one response shape that ever + // contains a literal `"ok":` field at all - every query command + // (`clients`/`monitors`/`workspaces`/`keyboard_layout`) returns a + // differently-shaped body with no such field, so this can't misfire + // against one of those. A plain substring check, not real JSON + // parsing, on purpose: `crates/platform/src/ipc.rs`'s `err`/`ok` + // helpers always emit this exact literal (`serde_json` preserves + // struct field declaration order), and this binary is deliberately + // dumb/dependency-free - see this file's own module doc comment. + if response.contains(r#""ok":false"#) { std::process::exit(1); } } @@ -48,16 +105,150 @@ fn build_request(args: &[String]) -> Result { match args.first().map(String::as_str) { Some("clients") => Ok(r#"{"cmd":"clients"}"#.to_string()), Some("monitors") => Ok(r#"{"cmd":"monitors"}"#.to_string()), + Some("workspaces") => Ok(r#"{"cmd":"workspaces"}"#.to_string()), + Some("keyboard") if args.get(1).map(String::as_str) == Some("layout") => Ok(r#"{"cmd":"keyboard_layout"}"#.to_string()), + Some("keyboard") => Err("did you mean 'srd keyboard layout'?".to_string()), Some("subscribe") => Ok(r#"{"cmd":"subscribe"}"#.to_string()), - Some("dispatch") => { - let action = args.get(1).ok_or("dispatch needs an action (toggle_visibility/focus/close/toggle_maximize/toggle_fullscreen)")?; - if !matches!(action.as_str(), "toggle_visibility" | "focus" | "close" | "toggle_maximize" | "toggle_fullscreen") { - return Err(format!("unknown dispatch action '{action}'")); + Some("dispatch") => build_dispatch(&args[1..]), + // `srd capture workspace [x]` - an + // off-screen render of that workspace's window tree, written to + // `path` as a PPM image. Not under `dispatch`: every `dispatch` + // action mutates window/workspace state, this only reads it. Sits + // next to `clients`/`monitors` in spirit, just backend-owned + // (needs a renderer) rather than answerable from core state alone + // - see `WindowManager::request_capture_workspace`'s own doc + // comment for why this exists (a workspace switcher's thumbnail + // for a workspace that isn't the one currently on screen, which no + // screencopy protocol can see). + Some("capture") => { + if args.get(1).map(String::as_str) != Some("workspace") { + return Err("'capture' only supports 'workspace' - usage: srd capture workspace [x]".to_string()); } - let id: u64 = args.get(2).ok_or("dispatch needs a window id")?.parse().map_err(|_| "window id must be a number".to_string())?; - Ok(format!(r#"{{"cmd":"{action}","id":{id}}}"#)) + let id: u64 = args.get(2).ok_or("capture needs a workspace id")?.parse().map_err(|_| "id must be a number".to_string())?; + let path = args.get(3).ok_or("capture needs an output path")?; + let size = match args.get(4) { + None => String::new(), + Some(spec) => { + let (w, h) = spec.split_once('x').ok_or("size must look like x")?; + let w: u64 = w.parse().map_err(|_| "width must be a number".to_string())?; + let h: u64 = h.parse().map_err(|_| "height must be a number".to_string())?; + format!(r#","width":{w},"height":{h}"#) + } + }; + Ok(format!(r#"{{"cmd":"capture_workspace","id":{id},"path":{path:?}{size}}}"#)) + } + // Value encoding is picked per key rather than guessed from the + // string's shape (e.g. treating anything that parses as a number + // as one): `border_color` is a hex string that happens to start + // with punctuation, not a bare word, and JSON's `true`/`false` + // need to land unquoted for the server's `as_bool()` to see them + // as booleans at all, not a string it then has to reject. + Some("set") => { + let key = args + .get(1) + .ok_or("set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/shadows/rounded_corners/decoration_mode)")?; + let raw = args.get(2).ok_or("set needs a value")?; + let value = match key.as_str() { + "border_width" | "corner_radius" | "gap_inner" | "gap_outer" => { + raw.parse::().map_err(|_| format!("{key} needs a numeric value"))?.to_string() + } + "shadows" | "rounded_corners" => match raw.as_str() { + "true" | "false" => raw.clone(), + _ => return Err(format!("{key} needs 'true' or 'false'")), + }, + "decoration_mode" => match raw.as_str() { + "server" | "client" => format!("{:?}", raw), + _ => return Err(format!("{key} needs 'server' or 'client'")), + }, + "border_color" => format!("{:?}", raw), + _ => return Err(format!("unknown set key '{key}'")), + }; + Ok(format!(r#"{{"cmd":"set","key":"{key}","value":{value}}}"#)) } - _ => Err("expected 'clients', 'monitors', 'subscribe', or 'dispatch '".to_string()), + _ => Err( + "expected 'clients', 'monitors', 'workspaces', 'keyboard layout', 'subscribe', 'dispatch ', 'capture workspace ', or 'set '" + .to_string(), + ), + } +} + +/// `dispatch`'s own verb-phrase parser, split out of `build_request` for +/// its own sake - reads like a real command (`toggle floating`, `move +/// window`), not a single joined snake_case token (`toggle_floating`). +/// Live report: a joined identifier reads as an internal detail leaking +/// into the CLI surface, not a real command a person would type. +/// +/// `set`'s keys (`corner_radius`, `border_color`, ...) deliberately keep +/// their existing snake_case names, unlike `dispatch`'s actions - they're +/// property/setting names, not verb phrases (the same distinction `git +/// config user.name` or `systemctl set-property CPUQuota=` draw: a key +/// stays an identifier, a command reads like English), and renaming them +/// would break the AGS peer's already-working live Settings panel, which +/// calls `srd set` directly today. `dispatch`'s actions carry no such +/// cost yet - the peer's own integration for these is either brand new +/// (the four just added) or already being migrated to `zwlr-foreign- +/// toplevel` instead of `srd dispatch` entirely, so this is the moment to +/// fix the syntax before more depends on it, not after. +fn build_dispatch(args: &[String]) -> Result { + let verb = args.first().ok_or("dispatch needs an action, e.g. 'focus', 'close', 'toggle maximize', 'move window'")?; + let usage_hint = + "expected one of: focus, close, lock, toggle visibility/maximize/fullscreen/floating/pinned, move window/workspace, activate workspace, cycle keyboard layout"; + match verb.as_str() { + "focus" | "close" => { + let id: u64 = args.get(1).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?; + Ok(format!(r#"{{"cmd":"{verb}","id":{id}}}"#)) + } + // No id - there's only ever one session to lock. + "lock" => Ok(r#"{"cmd":"lock"}"#.to_string()), + "toggle" => { + let noun = args.get(1).ok_or("'toggle' needs a target: visibility, maximize, fullscreen, floating, or pinned")?; + let cmd = match noun.as_str() { + "visibility" => "toggle_visibility", + "maximize" => "toggle_maximize", + "fullscreen" => "toggle_fullscreen", + "floating" => "toggle_floating", + "pinned" => "toggle_pinned", + _ => return Err(format!("unknown 'toggle' target '{noun}' - {usage_hint}")), + }; + let id: u64 = args.get(2).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?; + Ok(format!(r#"{{"cmd":"{cmd}","id":{id}}}"#)) + } + "move" => { + let noun = args.get(1).ok_or("'move' needs a target: window or workspace")?; + let id: u64 = args.get(2).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?; + match noun.as_str() { + "window" => { + let direction = args.get(3).ok_or("'move window' needs a direction (left/right/up/down)")?; + if !matches!(direction.as_str(), "left" | "right" | "up" | "down") { + return Err(format!("direction must be one of: left, right, up, down (got '{direction}')")); + } + Ok(format!(r#"{{"cmd":"move_window","id":{id},"direction":"{direction}"}}"#)) + } + "workspace" => { + let workspace: u64 = + args.get(3).ok_or("'move workspace' needs a target workspace")?.parse().map_err(|_| "workspace must be a number".to_string())?; + Ok(format!(r#"{{"cmd":"move_to_workspace","id":{id},"workspace":{workspace}}}"#)) + } + _ => Err(format!("unknown 'move' target '{noun}' - {usage_hint}")), + } + } + "activate" => { + if args.get(1).map(String::as_str) != Some("workspace") { + return Err(format!("'activate' only supports 'workspace' - {usage_hint}")); + } + let id: u64 = args.get(2).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?; + Ok(format!(r#"{{"cmd":"activate_workspace","id":{id}}}"#)) + } + // No id - there's only ever one keyboard as far as this IPC is + // concerned, unlike every other action here which targets a + // specific window or workspace. + "cycle" => { + if args.get(1).map(String::as_str) != Some("keyboard") || args.get(2).map(String::as_str) != Some("layout") { + return Err(format!("'cycle' only supports 'keyboard layout' - {usage_hint}")); + } + Ok(r#"{"cmd":"cycle_keyboard_layout"}"#.to_string()) + } + _ => Err(format!("unknown dispatch action '{verb}' - {usage_hint}")), } } @@ -65,12 +256,117 @@ fn print_usage() { eprintln!("usage:"); eprintln!(" srd clients"); eprintln!(" srd monitors"); + eprintln!(" srd workspaces"); + eprintln!(" srd keyboard layout"); eprintln!(" srd subscribe"); - eprintln!(" srd dispatch toggle_visibility "); eprintln!(" srd dispatch focus "); + eprintln!(" srd dispatch lock"); eprintln!(" srd dispatch close "); - eprintln!(" srd dispatch toggle_maximize "); - eprintln!(" srd dispatch toggle_fullscreen "); + eprintln!(" srd dispatch toggle visibility "); + eprintln!(" srd dispatch toggle maximize "); + eprintln!(" srd dispatch toggle fullscreen "); + eprintln!(" srd dispatch toggle floating "); + eprintln!(" srd dispatch toggle pinned "); + eprintln!(" srd dispatch move window "); + eprintln!(" srd dispatch move workspace "); + eprintln!(" srd dispatch activate workspace "); + eprintln!(" srd dispatch cycle keyboard layout"); + eprintln!(" srd capture workspace [x]"); + eprintln!(" srd set border_width "); + eprintln!(" srd set border_color <#hex>"); + eprintln!(" srd set corner_radius "); + eprintln!(" srd set gap_inner "); + eprintln!(" srd set gap_outer "); + eprintln!(" srd set shadows "); + eprintln!(" srd set rounded_corners "); + eprintln!(" srd set decoration_mode "); +} + +#[cfg(test)] +mod tests { + use super::build_request; + + fn args(strs: &[&str]) -> Vec { + strs.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn toggle_floating_and_toggle_pinned_read_as_two_words() { + assert_eq!(build_request(&args(&["dispatch", "toggle", "floating", "42"])).unwrap(), r#"{"cmd":"toggle_floating","id":42}"#); + assert_eq!(build_request(&args(&["dispatch", "toggle", "pinned", "42"])).unwrap(), r#"{"cmd":"toggle_pinned","id":42}"#); + } + + #[test] + fn toggle_with_an_unknown_target_is_rejected_client_side() { + assert!(build_request(&args(&["dispatch", "toggle", "sideways", "42"])).is_err()); + } + + #[test] + fn capture_workspace_with_and_without_a_size() { + assert_eq!( + build_request(&args(&["capture", "workspace", "1", "/tmp/ws1.ppm"])).unwrap(), + r#"{"cmd":"capture_workspace","id":1,"path":"/tmp/ws1.ppm"}"# + ); + assert_eq!( + build_request(&args(&["capture", "workspace", "1", "/tmp/ws1.ppm", "200x120"])).unwrap(), + r#"{"cmd":"capture_workspace","id":1,"path":"/tmp/ws1.ppm","width":200,"height":120}"# + ); + assert!(build_request(&args(&["capture", "workspace", "1", "/tmp/ws1.ppm", "bogus"])).is_err()); + assert!(build_request(&args(&["capture", "workspace", "1"])).is_err(), "missing path must error"); + assert!(build_request(&args(&["capture", "monitor", "1", "/tmp/x.ppm"])).is_err(), "only 'workspace' is a valid capture target"); + } + + #[test] + fn move_window_needs_a_direction_and_validates_it() { + assert_eq!(build_request(&args(&["dispatch", "move", "window", "42", "left"])).unwrap(), r#"{"cmd":"move_window","id":42,"direction":"left"}"#); + assert!(build_request(&args(&["dispatch", "move", "window", "42"])).is_err(), "missing direction must error, not silently omit it"); + assert!(build_request(&args(&["dispatch", "move", "window", "42", "sideways"])).is_err(), "an invalid direction must be rejected client-side"); + } + + #[test] + fn move_workspace_needs_a_numeric_workspace() { + assert_eq!(build_request(&args(&["dispatch", "move", "workspace", "42", "2"])).unwrap(), r#"{"cmd":"move_to_workspace","id":42,"workspace":2}"#); + assert!(build_request(&args(&["dispatch", "move", "workspace", "42"])).is_err(), "missing workspace must error"); + assert!(build_request(&args(&["dispatch", "move", "workspace", "42", "not-a-number"])).is_err()); + } + + #[test] + fn lock_needs_no_id() { + assert_eq!(build_request(&args(&["dispatch", "lock"])).unwrap(), r#"{"cmd":"lock"}"#); + } + + #[test] + fn focus_and_close_stay_single_word_verbs() { + assert_eq!(build_request(&args(&["dispatch", "focus", "42"])).unwrap(), r#"{"cmd":"focus","id":42}"#); + assert_eq!(build_request(&args(&["dispatch", "close", "42"])).unwrap(), r#"{"cmd":"close","id":42}"#); + } + + #[test] + fn activate_workspace_and_cycle_keyboard_layout_read_as_real_phrases() { + assert_eq!(build_request(&args(&["dispatch", "activate", "workspace", "1"])).unwrap(), r#"{"cmd":"activate_workspace","id":1}"#); + assert_eq!(build_request(&args(&["dispatch", "cycle", "keyboard", "layout"])).unwrap(), r#"{"cmd":"cycle_keyboard_layout"}"#); + } + + #[test] + fn a_joined_snake_case_action_is_no_longer_accepted() { + // The old single-token spelling must not silently keep working -- + // a caller still using it should get a clear error, not a wrong + // command reaching the server. + assert!(build_request(&args(&["dispatch", "toggle_floating", "42"])).is_err()); + } + + #[test] + fn keyboard_layout_query_reads_as_two_words_too() { + assert_eq!(build_request(&args(&["keyboard", "layout"])).unwrap(), r#"{"cmd":"keyboard_layout"}"#); + assert!(build_request(&args(&["keyboard_layout"])).is_err()); + } + + #[test] + fn set_decoration_mode_accepts_only_server_or_client() { + assert_eq!(build_request(&args(&["set", "decoration_mode", "server"])).unwrap(), r#"{"cmd":"set","key":"decoration_mode","value":"server"}"#); + assert_eq!(build_request(&args(&["set", "decoration_mode", "client"])).unwrap(), r#"{"cmd":"set","key":"decoration_mode","value":"client"}"#); + assert!(build_request(&args(&["set", "decoration_mode", "both"])).is_err()); + } } #[cfg(unix)] diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index 3408c50..bc7ab77 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -11,6 +11,28 @@ log.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true +# `appmenu_registrar.rs`: owns `com.canonical.AppMenu.Registrar`, the +# classic Qt/`appmenu-qt5` global-menu D-Bus service every X11-capable +# backend needs - see that module's doc comment for why it lives here +# rather than in one specific backend crate. Default features (`async-io` + +# `blocking-api`) are already the lightest option zbus has - `async-io` is +# a small reactor, not a full runtime like tokio (which this compositor has +# no other reason to depend on) - and `blocking-api` is what lets that +# module drive it from a plain background thread with no `async`/`await` +# of its own. +zbus = "5" + +# `native_lock_auth.rs`: PAM authentication for srdwm's own session-lock UI +# (`crates/wayland/src/native_lock.rs`) - Unix-only (PAM itself is a +# Linux/BSD concept with no Windows/macOS equivalent, and this whole crate +# is also linked into the Windows/macOS backend crates), so gated the same +# way `crates/ctl`'s socket code already is rather than pulling libpam into +# builds that will never call this. `default-features = false`: the `cli` +# feature (and its `rpassword` dependency) is for reading a password +# interactively off a terminal, which nothing here needs - the password +# already comes from srdwm's own lock UI, not a TTY prompt. +[target.'cfg(unix)'.dependencies] +pam-client = { version = "0.5", default-features = false } [dev-dependencies] tempfile = "3" diff --git a/crates/platform/src/ipc.rs b/crates/platform/src/ipc.rs index 2b75b8e..8c58259 100644 --- a/crates/platform/src/ipc.rs +++ b/crates/platform/src/ipc.rs @@ -29,7 +29,7 @@ use std::path::PathBuf; use serde::Serialize; -use srdwm_core::{GlobalMenu, MenuSource, WindowId, WindowManager}; +use srdwm_core::{Direction, GlobalMenu, MenuSource, WindowId, WindowManager}; pub struct IpcServer { listener: UnixListener, @@ -46,6 +46,13 @@ pub struct IpcServer { /// no real change (the common case, since this runs every ~16ms) skips /// serializing and writing anything at all. last_broadcast: Vec, + /// `last_broadcast`'s workspace equivalent - diffed and pushed + /// independently, see `WorkspacesEvent`'s doc comment for why this + /// isn't folded into the field above. + last_broadcast_workspaces: Vec, + /// `last_broadcast`'s keyboard-layout equivalent - see + /// `KeyboardLayoutEvent`'s own doc comment. + last_broadcast_keyboard_layout: String, } impl IpcServer { @@ -70,7 +77,15 @@ impl IpcServer { 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() }) + Ok(Self { + listener, + path, + conns: Vec::new(), + subscribers: Vec::new(), + last_broadcast: Vec::new(), + last_broadcast_workspaces: Vec::new(), + last_broadcast_keyboard_layout: String::new(), + }) } /// Accepts any waiting connections, advances in-progress reads, and @@ -128,18 +143,63 @@ impl IpcServer { // same as before subscribe existed. false }); - self.subscribers.extend(new_subscribers); - - if !self.subscribers.is_empty() { - let current: Vec = client_snapshot(wm); - if current != self.last_broadcast { + // `last_broadcast`/`last_broadcast_workspaces` are kept in sync + // with reality unconditionally, whether or not anyone is actually + // subscribed right now - only the socket write itself is gated on + // `self.subscribers` being non-empty. This matters at the exact + // moment a new subscriber joins: their `"subscribe"` reply (built + // separately, inside `handle_request`, from its own fresh + // `client_snapshot`/`workspace_snapshot` call) already sent them a + // full current snapshot, but `new_subscribers` hasn't been merged + // into `self.subscribers` yet at this point in `poll` - so + // `self.subscribers` may still be empty here even though a reply + // just went out. Skipping the *sync* as well as the write (an + // earlier version of this gated both behind one `is_empty` check) + // left `last_broadcast*` stale until the next real change, so the + // very next tick's diff saw a mismatch against what the new + // subscriber was already sent and pushed a redundant duplicate -- + // for clients this coincidentally never fired (an empty window + // list at construction matches `last_broadcast`'s own empty + // starting value), but workspaces are never empty (`WindowManager + // ::new` always seeds one), so every first subscriber got a + // spurious extra `workspaces` line one tick after connecting. + // Always syncing, and only conditionally writing, keeps both + // invariants true at once: a fresh subscriber's direct reply is + // never redundantly repeated, and an *existing* subscriber still + // gets notified of any real change that happens in the same tick + // a new one joins, since the diff against the old subscriber list + // runs before `new_subscribers` is merged in below regardless. + let current: Vec = client_snapshot(wm); + if current != self.last_broadcast { + if !self.subscribers.is_empty() { 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; } + self.last_broadcast = current; + } + let current_workspaces: Vec = workspace_snapshot(wm); + if current_workspaces != self.last_broadcast_workspaces { + if !self.subscribers.is_empty() { + if let Ok(mut out) = serde_json::to_vec(&WorkspacesEvent { event: "workspaces", workspaces: ¤t_workspaces }) { + out.push(b'\n'); + self.subscribers.retain_mut(|s| s.write_all(&out).is_ok()); + } + } + self.last_broadcast_workspaces = current_workspaces; } + let current_layout = wm.borrow().keyboard_layout.clone(); + if current_layout != self.last_broadcast_keyboard_layout { + if !self.subscribers.is_empty() { + if let Ok(mut out) = serde_json::to_vec(&KeyboardLayoutEvent { event: "keyboard_layout", layout: ¤t_layout }) { + out.push(b'\n'); + self.subscribers.retain_mut(|s| s.write_all(&out).is_ok()); + } + } + self.last_broadcast_keyboard_layout = current_layout; + } + self.subscribers.extend(new_subscribers); dirty } } @@ -170,6 +230,18 @@ struct ClientInfo { // width/height terms, to a floating window a human dragged flush // against it. floating: bool, + // The active layout's own name for this window's workspace (`"dynamic"` + // by default, `"tiling"` once a workspace opts in, or any name a config + // registered via `srd.layout.configure`) - added because `floating` + // alone can't answer "did *no* layout place this window" for an AGS + // peer session's dock auto-hide logic: `floating` only reflects the + // explicit per-window flag (scratchpad/rules/`srd.window.toggle_ + // floating`), which stays `false` by default even under `"dynamic"`, + // the no-op layout that never places anything - every window on it is + // effectively free-positioned regardless of what `floating` says. A + // consumer that wants "is this window actually being tiled" needs + // both: `layout` names a real tiling layout AND `floating` is false. + layout: String, // 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 @@ -206,18 +278,29 @@ struct GlobalMenuInfo { menu_path: Option, app_path: Option, window_path: Option, - // 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. + // Which export flavour `menu_path` actually came from, and - as of + // `MenuSource::DbusMenu` - which *protocol* it's actually in, not + // just which action-group prefix to use. Getting the prefix wrong + // (gtk/unity) renders a menu with every item permanently insensitive; + // getting the protocol wrong (unity/dbusmenu) renders no menu at all, + // since `Gio.DBusMenuModel` silently returns an empty model against a + // `com.canonical.dbusmenu` object rather than erroring - both are + // real failure modes an AGS peer session hit live, which is why this + // is three values, not two: + // + // - "gtk": a real `GMenuModel`. Actions are `app.xxx`/`win.xxx`, + // resolved against `app_path`/`window_path` under those two + // prefixes. + // - "unity": still `GMenuModel`/`org.gtk.Menus` underneath (same + // consumer as "gtk") - `appmenu-gtk-module`'s compatibility shim, + // which serves it under one `unity.xxx`-prefixed group at + // `menu_path` itself instead of `app`/`win`. Some XWayland clients + // 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. + // - "dbusmenu": a genuinely different wire protocol, + // `com.canonical.dbusmenu` - needs an actual dbusmenu client, not a + // `GMenuModel` read under any prefix. source: &'static str, } @@ -226,6 +309,7 @@ impl From<&GlobalMenu> for GlobalMenuInfo { let source = match m.source { MenuSource::Gtk => "gtk", MenuSource::Unity => "unity", + MenuSource::DbusMenu => "dbusmenu", }; 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 } } @@ -241,6 +325,42 @@ struct MonitorsResponse { monitors: Vec, } +#[derive(Serialize)] +struct WorkspacesResponse { + workspaces: Vec, +} + +/// `"keyboard_layout"`'s one-shot reply shape - the active XKB layout's +/// own name (e.g. `"English (US)"`), whatever `WindowManager::keyboard_ +/// layout` currently holds. Added for an AGS peer session's keyboard- +/// layout badge, the last Hyprland-only control left in their shell +/// (`hyprctl devices -j` had no srdwm equivalent at all before this). +#[derive(Serialize)] +struct KeyboardLayoutResponse { + layout: String, +} + +/// One entry per `srdwm_core::Workspace` - added so an external panel (an +/// AGS peer session's workspace pills/Overview) can enumerate and switch +/// workspaces through this socket instead of a separate protocol client. +/// Deliberately no `urgent`/attention-request field: nothing in srdwm +/// tracks that concept anywhere yet (confirmed - `crates/wayland/src/ +/// workspace.rs`'s own `ext-workspace-v1` implementation only ever sends +/// `State::Active` or empty, never a `Urgent` bit), so adding one here +/// would mean inventing what "urgent" means with no real signal behind it +/// rather than exposing something that already exists. A real design +/// (what sets it - an `xdg_toplevel` has no urgency concept at all; X11's +/// `_NET_WM_STATE_DEMANDS_ATTENTION` is the only actual signal on this +/// compositor today, and it's XWayland-only) belongs as its own piece of +/// work, not a field guessed at here. +#[derive(Serialize, Clone, PartialEq)] +struct WorkspaceInfo { + id: usize, + name: String, + layout: String, + active: bool, +} + /// 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 @@ -280,6 +400,30 @@ struct ClientsEvent<'a> { clients: &'a [ClientInfo], } +/// `WorkspaceInfo`'s equivalent of `ClientsEvent` - a distinct event on +/// the same `subscribe` connection (one JSON object per line, `"event"` +/// says which), not folded into `ClientsEvent`: workspaces and windows +/// change independently (switching workspace touches no window; a window +/// closing touches no workspace), so diffing and broadcasting them +/// together would push a workspace-shaped payload on every window change +/// and vice versa for no reason. +#[derive(Serialize)] +struct WorkspacesEvent<'a> { + event: &'static str, + workspaces: &'a [WorkspaceInfo], +} + +/// A third, independently-diffed event on the same `subscribe` connection +/// - see `WorkspacesEvent`'s own doc comment for why this isn't folded +/// into either of the other two: a layout cycle touches no window and no +/// workspace, so it needs its own change-diff to avoid pushing an +/// unrelated payload on every unrelated change. +#[derive(Serialize)] +struct KeyboardLayoutEvent<'a> { + event: &'static str, + layout: &'a str, +} + /// 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. @@ -298,6 +442,7 @@ fn client_snapshot(wm: &std::rc::Rc>) -> Vec>) -> Vec>) -> Vec { + let wm = wm.borrow(); + let current = wm.current_workspace(); + wm.workspaces().iter().map(|w| WorkspaceInfo { id: w.id, name: w.name.clone(), layout: w.layout.clone(), active: w.id == current }).collect() +} + #[derive(Serialize)] struct OkResponse { ok: bool, @@ -322,6 +477,23 @@ fn err(msg: &'static str) -> Vec { serde_json::to_vec(&OkResponse { ok: false, error: Some(msg) }).unwrap_or_default() } +/// The `move_window` dispatch's own direction-name parser - `crates/ +/// config`'s own `parse_direction` (used by `srd.window.move`) isn't +/// reusable here: different crate, and it returns an `mlua::Result` tied +/// to the Lua binding's own error type. Same four names, same "small +/// duplication across crate boundaries beats a cross-crate dependency for +/// four match arms" tradeoff every other bit of shared naming in this +/// codebase already accepts. +fn parse_direction(name: &str) -> Option { + match name { + "left" => Some(Direction::Left), + "right" => Some(Direction::Right), + "up" => Some(Direction::Up), + "down" => Some(Direction::Down), + _ => None, + } +} + /// 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>) -> (Vec, bool) { @@ -333,6 +505,7 @@ fn handle_request(line: &[u8], wm: &std::rc::Rc (serde_json::to_vec(&ClientsResponse { clients: client_snapshot(wm) }).unwrap_or_default(), false), + "workspaces" => (serde_json::to_vec(&WorkspacesResponse { workspaces: workspace_snapshot(wm) }).unwrap_or_default(), false), "monitors" => { let monitors: Vec = wm .borrow() @@ -360,8 +533,28 @@ fn handle_request(line: &[u8], wm: &std::rc::Rc { + // Three JSON objects, not one: `poll` writes this response plus + // one trailing `\n` verbatim, so an embedded `\n` between each + // here is all it takes to hand a fresh subscriber every initial + // snapshot as its own line - exactly the shape every later + // push already uses, so there's nothing for a consumer to + // special-case about the first few lines it reads. let clients = client_snapshot(wm); - (serde_json::to_vec(&ClientsEvent { event: "clients", clients: &clients }).unwrap_or_default(), false) + let workspaces = workspace_snapshot(wm); + let layout = wm.borrow().keyboard_layout.clone(); + let mut out = serde_json::to_vec(&ClientsEvent { event: "clients", clients: &clients }).unwrap_or_default(); + out.push(b'\n'); + out.extend(serde_json::to_vec(&WorkspacesEvent { event: "workspaces", workspaces: &workspaces }).unwrap_or_default()); + out.push(b'\n'); + out.extend(serde_json::to_vec(&KeyboardLayoutEvent { event: "keyboard_layout", layout: &layout }).unwrap_or_default()); + (out, false) + } + "keyboard_layout" => { + (serde_json::to_vec(&KeyboardLayoutResponse { layout: wm.borrow().keyboard_layout.clone() }).unwrap_or_default(), false) + } + "cycle_keyboard_layout" => { + wm.borrow_mut().request_keyboard_layout_cycle(); + (ok(), true) } "toggle_visibility" => { let Some(id) = id else { return (err("missing id"), false) }; @@ -394,6 +587,16 @@ fn handle_request(line: &[u8], wm: &std::rc::Rc { + wm.borrow_mut().request_lock(); + (ok(), true) + } // `srd.window.maximize()`/`.fullscreen()`'s exact IPC-side // equivalents - lets an external script (or a live diagnostic // check, same as `toggle_visibility`/`focus`/`close` already allow) @@ -408,10 +611,279 @@ fn handle_request(line: &[u8], wm: &std::rc::Rc { + let Some(id) = id else { return (err("missing id"), false) }; + wm.borrow_mut().toggle_floating(id); + (ok(), true) + } + "toggle_pinned" => { + let Some(id) = id else { return (err("missing id"), false) }; + wm.borrow_mut().toggle_always_on_top(id); + (ok(), true) + } + // `{"cmd":"move_window","id":,"direction":"left"|"right"|"up"|"down"}` + // - `WindowManager::move_window_direction` swaps the *focused* + // window with its neighbour in that direction, so a caller asking + // to move a window that isn't currently focused needs it focused + // first; matches `movewindow` needing the target window active in + // every tiling WM this gesture is modeled on. + "move_window" => { + let Some(id) = id else { return (err("missing id"), false) }; + let Some(dir) = req.get("direction").and_then(|v| v.as_str()).and_then(parse_direction) else { + return (err("direction must be one of: left, right, up, down"), false); + }; + let mut wm = wm.borrow_mut(); + if wm.focused_id() != Some(id) { + wm.focus_window(id); + } + wm.move_window_direction(dir); + (ok(), true) + } + // `{"cmd":"move_to_workspace","id":,"workspace":}` + // - the operation the AGS peer's Overview needs for drag-a-window- + // onto-another-workspace, which `ext-workspace-v1` (activation + // only, no toplevel-to-workspace verb) and `zwlr-foreign-toplevel` + // (no workspace concept at all) both lack entirely. + "move_to_workspace" => { + let Some(id) = id else { return (err("missing id"), false) }; + let Some(workspace) = req.get("workspace").and_then(|v| v.as_u64()) else { + return (err("missing workspace"), false); + }; + wm.borrow_mut().move_window_to_workspace(id, workspace as srdwm_core::WorkspaceId); + (ok(), true) + } + // The workspace-side equivalent of `focus`: `id` here is a + // `WorkspaceId`, not a `WindowId` - both are plain `usize`/`u64` + // on the wire, so the same generic `id` field this whole match + // already reads serves both, same as every other dispatch arm. + "activate_workspace" => { + let Some(id) = id else { return (err("missing id"), false) }; + wm.borrow_mut().switch_workspace(id as srdwm_core::WorkspaceId); + (ok(), true) + } + // `{"cmd":"set_output_position","id":,"x":,"y":}` + // - the primitive an output-configuration UI (a display-settings + // panel, concretely the monitor-mirroring toggle this was built + // for) needs and had no way to reach before: `wlr-output- + // management-v1` already supports repositioning an output + // (`crates/wayland/src/output_management.rs`), but only to a + // client willing to implement that whole protocol itself just to + // move one output. This exposes the same capability over the + // plain IPC socket every other `srd dispatch` action already + // uses. Deliberately just "move this output" with no separate + // "mirror" concept anywhere: positioning two outputs at the same + // coordinates already shows the same desktop region on both (every + // window/render decision downstream works in shared global space, + // not per-output), so mirroring is something a caller *achieves* + // with this primitive, not something srdwm needs to know about as + // its own state. + // + // Not applied here, and deliberately not a `WindowId` on the wire + // despite reusing the same `id` field every other dispatch already + // reads (both are plain integers on the wire; only the Rust-side + // type differs) - this crate has no real output handle to move, + // only `WindowManager`'s passive mirror of whatever the backend + // last reported. Queued via `request_output_position` and applied + // by whichever backend actually owns the hardware on its own next + // poll, the same one-poll-tick latency every other backend-owned + // effect in this IPC layer already has (a redraw, a geometry + // change) - `changed = true` still makes sense to return since + // this genuinely will change what's on screen once the backend + // catches up, just not synchronously within this call. + "set_output_position" => { + let Some(id) = id else { return (err("missing id"), false) }; + let (Some(x), Some(y)) = (req.get("x").and_then(|v| v.as_i64()), req.get("y").and_then(|v| v.as_i64())) else { + return (err("missing x/y"), false); + }; + wm.borrow_mut().request_output_position(id as srdwm_core::MonitorId, x as i32, y as i32); + (ok(), true) + } + // `{"cmd":"capture_workspace","id":,"path":, + // "width":,"height":}` - `width`/`height` are optional, + // both or neither. Exists for a workspace switcher's thumbnail + // previews (AGS's Overview): `wlr-screencopy` - what `grim` and + // this compositor's own `screencopy.rs` both use - can only ever + // capture what an output is currently *presenting*, so a workspace + // that isn't the active one is structurally invisible to it. This + // is the one thing screencopy can't do, queued the same + // cross-boundary way `set_output_position` is (core has no + // renderer of its own) and drained by whichever backend is + // actually running on its own next poll. Same one-poll-tick + // latency as every other backend-owned effect this IPC layer + // already has - the file exists shortly after this call returns, + // not necessarily before it. + "capture_workspace" => { + let Some(id) = id else { return (err("missing id"), false) }; + let Some(path) = req.get("path").and_then(|v| v.as_str()) else { + return (err("missing path"), false); + }; + let size = match (req.get("width").and_then(|v| v.as_u64()), req.get("height").and_then(|v| v.as_u64())) { + (Some(w), Some(h)) => Some((w as u32, h as u32)), + (None, None) => None, + _ => return (err("width and height must both be given, or neither"), false), + }; + wm.borrow_mut().request_capture_workspace(id as srdwm_core::WorkspaceId, path.to_string(), size); + (ok(), true) + } + // Live theme values - an AGS peer session's equivalent of + // Hyprland's `hyprctl keyword general:col.active_border ...`, the + // mechanism their shell already uses to repaint window borders the + // instant an accent palette/radius/etc changes in Settings. Was a + // real, invisible gap before this: every one of these already had + // a real, mutable `WindowManager` field (`theme.default_border_*`, + // `tiling.gap_*`, `shadows_enabled`, `rounded_corners_enabled`), + // set once from Lua config at startup and never touched again -- + // so a running session had no way to change any of it without a + // full restart, unlike everything else `srd dispatch` already + // covers live. + // + // No extra redraw call needed here: returning `changed = true` + // (same as every other mutating command) is exactly what makes + // `main.rs`'s `sync()` run its next tick, which already calls + // `redraw_decoration`/`apply_geometry` for every visible window + // unconditionally - this only has to mutate the right field and + // let that existing machinery do the rest. + "set" => handle_set(&req, wm), _ => (err("unknown command"), false), } } +/// `{"cmd":"set","key":"border_width","value":3}` and the rest of `"set"`'s +/// keys - pulled out of `handle_request`'s match arm purely to keep that +/// match's per-arm bodies roughly the same size; no reuse motive. +/// +/// A window's `border_color`/`border_width` are copied from `theme. +/// default_border_color`/`default_border_width` once, at creation +/// (`WindowManager::add_window`), and a rule's explicit `border_color`/ +/// `border_width` action can overwrite that afterward - so a window +/// carrying the *old* default is, in practice, exactly the set of windows +/// that never had a rule override it (a rule-set colour coincidentally +/// equal to today's default is the only false positive, and updating it +/// to the new default too is a reasonable outcome, not a real bug). That +/// predicate is what the two colour/width arms below walk existing +/// windows with, rather than touching every window unconditionally. +fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc>) -> (Vec, bool) { + let key = req.get("key").and_then(|v| v.as_str()).unwrap_or(""); + let value = req.get("value"); + match key { + "border_width" => { + let Some(width) = value.and_then(|v| v.as_u64()) else { return (err("border_width needs a numeric value"), false) }; + let width = width as u32; + let mut wm = wm.borrow_mut(); + let old = wm.theme.default_border_width; + wm.theme.default_border_width = width; + let matching: Vec<_> = wm.windows().filter(|w| w.border_width == old).map(|w| w.id).collect(); + for id in matching { + if let Some(w) = wm.window_mut(id) { + w.border_width = width; + } + } + (ok(), true) + } + "border_color" => { + let Some(hex) = value.and_then(|v| v.as_str()) else { return (err("border_color needs a hex string value"), false) }; + let Some(rgb) = srdwm_core::parse_hex_color(hex) else { return (err("border_color must be a hex string like #cba6f7"), false) }; + let mut wm = wm.borrow_mut(); + let old = wm.theme.default_border_color; + wm.theme.default_border_color = rgb; + let matching: Vec<_> = wm.windows().filter(|w| w.border_color == old).map(|w| w.id).collect(); + for id in matching { + if let Some(w) = wm.window_mut(id) { + w.border_color = rgb; + } + } + (ok(), true) + } + // `border_width`'s exact twin, for the titlebar/border-strip corner + // radius - same "only touch windows still carrying the old + // default" predicate, so a window a rule already gave its own + // explicit `corner_radius` isn't silently overwritten by a later + // live-set. + "corner_radius" => { + let Some(radius) = value.and_then(|v| v.as_u64()) else { return (err("corner_radius needs a numeric value"), false) }; + let radius = radius as u32; + let mut wm = wm.borrow_mut(); + let old = wm.theme.default_corner_radius; + wm.theme.default_corner_radius = radius; + let matching: Vec<_> = wm.windows().filter(|w| w.corner_radius == old).map(|w| w.id).collect(); + for id in matching { + if let Some(w) = wm.window_mut(id) { + w.corner_radius = radius; + } + } + (ok(), true) + } + // Live A/B-testing knob for `srdwm_core::ThemeConfig:: + // default_decorated` - see its own doc comment for the "which + // desktop environment does what" reasoning behind making this + // configurable at all. Deliberately only affects windows created + // *after* this call, not existing ones - retroactively flipping + // an already-mapped window's decoration needs the same redraw-buffer + // + geometry-resync `set_decorated_from_mode` does on the Wayland + // side (backend-specific, unreachable from this backend-agnostic + // `crates/platform` code), and the actual use case here is testing + // which default a freshly opened app gets, not live-migrating + // windows already on screen. + "decoration_mode" => { + let Some(mode) = value.and_then(|v| v.as_str().map(str::to_string)) else { + return (err("decoration_mode needs \"server\" or \"client\""), false); + }; + if mode != "server" && mode != "client" { + return (err("decoration_mode needs \"server\" or \"client\""), false); + } + wm.borrow_mut().theme.default_decorated = mode != "client"; + (ok(), true) + } + // Tiling-only: `arrange_workspace` skips floating/fullscreen + // windows regardless, and under `"dynamic"` (the no-op default + // layout) nothing reads `tiling.gap_*` at all - so setting these + // is a correct no-op, visually, until a workspace actually runs + // the `"tiling"` layout, exactly matching what Hyprland's own + // `general:gaps_*` do under its own non-tiling/floating windows. + "gap_inner" => { + let Some(v) = value.and_then(|v| v.as_u64()) else { return (err("gap_inner needs a numeric value"), false) }; + wm.borrow_mut().tiling.gap_inner = v as u32; + (ok(), true) + } + "gap_outer" => { + let Some(v) = value.and_then(|v| v.as_u64()) else { return (err("gap_outer needs a numeric value"), false) }; + wm.borrow_mut().tiling.gap_outer = v as u32; + (ok(), true) + } + "shadows" => { + let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("shadows needs a boolean value"), false) }; + wm.borrow_mut().shadows_enabled = v; + (ok(), true) + } + // A bool, not a radius: the actual corner radius is a fixed + // constant (`crates/wayland/src/decoration.rs::CORNER_RADIUS`), + // not a per-session config value anywhere in the compositor yet -- + // this can only turn rounding on/off, matching `WindowManager:: + // rounded_corners_enabled`'s existing `Option` shape (also + // config-settable at startup via `general.rounded_corners`, never + // live until now). A live-settable numeric radius is real, separate + // future work, not something to fake here with a value that's + // silently ignored. + "rounded_corners" => { + let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("rounded_corners needs a boolean value"), false) }; + wm.borrow_mut().rounded_corners_enabled = Some(v); + (ok(), true) + } + "blur" => (err("blur is not supported - no GPU shader path on this compositor's software renderer yet"), false), + _ => (err("unknown set key"), false), + } +} + #[cfg(test)] mod tests { use super::*; @@ -419,12 +891,23 @@ mod tests { 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()); + /// Takes a reader the caller already owns, rather than building a fresh + /// `BufReader` around a cloned handle every call (what this used to do): + /// `BufRead::read_line` is free to read further ahead than one line in + /// a single syscall whenever more is already sitting in the kernel + /// socket buffer - true the moment a caller (like `"subscribe"`'s + /// two-line initial reply, added alongside the workspace event) writes + /// more than one line in one `write_all`. A fresh `BufReader` built + /// per call has nowhere to keep whatever it over-read once the call + /// returns and it's dropped - that data is already gone from the + /// kernel's queue, so the *next* fresh `BufReader`'s read blocks + /// forever waiting for bytes that already arrived and were silently + /// discarded. Hung an entire test run silently, with no compiler error + /// and no panic to point at it, until the underlying `cargo test` + /// process was found sitting at 0% CPU with no explanation. One + /// `BufReader`, reused for every `read_line` call in a test, keeps + /// whatever it over-reads available for the next call instead. + fn read_line(reader: &mut std::io::BufReader) -> String { let mut line = String::new(); reader.read_line(&mut line).unwrap(); line @@ -437,12 +920,27 @@ mod tests { let wm = Rc::new(RefCell::new(WindowManager::new())); let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().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":[]"#)); + // Three lines, not one: a client snapshot, a workspace snapshot, + // and a keyboard-layout snapshot, same as every later push - see + // the `"subscribe"` match arm's own doc comment for why all three + // are sent immediately rather than waiting for the first real + // change of each kind. + let clients_line = read_line(&mut reader); + assert!(clients_line.contains(r#""event":"clients""#)); + assert!(clients_line.contains(r#""clients":[]"#)); + + let workspaces_line = read_line(&mut reader); + assert!(workspaces_line.contains(r#""event":"workspaces""#)); + assert!(workspaces_line.contains(r#""id":0"#)); + assert!(workspaces_line.contains(r#""active":true"#)); + + let layout_line = read_line(&mut reader); + assert!(layout_line.contains(r#""event":"keyboard_layout""#)); + assert!(layout_line.contains(r#""layout":""#)); } #[test] @@ -452,9 +950,12 @@ mod tests { let wm = Rc::new(RefCell::new(WindowManager::new())); let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); server.poll(&wm); - let _initial = read_line(&mut client); + let _initial_clients = read_line(&mut reader); + let _initial_workspaces = read_line(&mut reader); + let _initial_layout = read_line(&mut reader); { let mut wm = wm.borrow_mut(); @@ -463,11 +964,37 @@ mod tests { } server.poll(&wm); - let pushed = read_line(&mut client); + let pushed = read_line(&mut reader); assert!(pushed.contains(r#""event":"clients""#)); assert!(pushed.contains(r#""title":"hello""#)); } + #[test] + fn subscribe_then_a_workspace_switch_pushes_a_fresh_workspaces_event() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + wm.borrow_mut().add_workspace("2", "dynamic"); + + let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); + server.poll(&wm); + let _initial_clients = read_line(&mut reader); + let _initial_workspaces = read_line(&mut reader); + let _initial_layout = read_line(&mut reader); + + wm.borrow_mut().switch_workspace(1); + server.poll(&wm); + + // Switching touches no window, so the clients list is unchanged -- + // the next line waiting must be the workspaces push, not a + // clients one that never comes. + let pushed = read_line(&mut reader); + assert!(pushed.contains(r#""event":"workspaces""#)); + assert!(pushed.contains(r#""id":1,"name":"2","layout":"dynamic","active":true"#)); + } + #[test] fn a_poll_with_no_real_change_pushes_nothing_new() { let dir = tempfile::tempdir().unwrap(); @@ -475,9 +1002,12 @@ mod tests { let wm = Rc::new(RefCell::new(WindowManager::new())); let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); server.poll(&wm); - let _initial = read_line(&mut client); + let _initial_clients = read_line(&mut reader); + let _initial_workspaces = read_line(&mut reader); + let _initial_layout = read_line(&mut reader); // Nothing changed between these two polls - a second push would // show up as a second readable line the client isn't expecting. @@ -491,6 +1021,195 @@ mod tests { } } + #[test] + fn workspaces_command_reports_the_default_workspace() { + 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(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(b"{\"cmd\":\"workspaces\"}\n").unwrap(); + server.poll(&wm); + + let line = read_line(&mut reader); + assert!(!line.contains(r#""event""#), "one-shot command, same plain shape as \"clients\""); + assert!(line.contains(r#""id":0,"name":"1","layout":"dynamic","active":true"#)); + } + + #[test] + fn keyboard_layout_command_reports_the_current_layout() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + wm.borrow_mut().set_keyboard_layout("English (US)"); + + let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(b"{\"cmd\":\"keyboard_layout\"}\n").unwrap(); + server.poll(&wm); + + let line = read_line(&mut reader); + assert!(!line.contains(r#""event""#), "one-shot command, same plain shape as \"clients\""); + assert!(line.contains(r#""layout":"English (US)""#)); + } + + #[test] + fn cycle_keyboard_layout_dispatch_queues_a_request_for_main_rs_to_act_on() { + 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(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(b"{\"cmd\":\"cycle_keyboard_layout\"}\n").unwrap(); + server.poll(&wm); + let _ = read_line(&mut reader); + + // `IpcServer` has no seat/keyboard of its own to actually cycle -- + // it can only queue the intent for `main.rs`'s `sync()` to act on, + // same as `close_requests`/`activate_workspace`. This is as far as + // this crate can verify the request landed. + assert_eq!(wm.borrow_mut().take_keyboard_layout_cycle_requests(), 1); + } + + #[test] + fn activate_workspace_dispatch_switches_the_current_workspace() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + wm.borrow_mut().add_workspace("2", "dynamic"); + + let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(b"{\"cmd\":\"activate_workspace\",\"id\":1}\n").unwrap(); + server.poll(&wm); + let _ = read_line(&mut reader); + + assert_eq!(wm.borrow().current_workspace(), 1); + } + + #[test] + fn lock_dispatch_queues_a_lock_request_with_no_id_needed() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + assert!(!wm.borrow_mut().drain_lock_request(), "nothing queued before the dispatch"); + + let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(b"{\"cmd\":\"lock\"}\n").unwrap(); + server.poll(&wm); + let response = read_line(&mut reader); + + assert!(!response.contains(r#""ok":false"#), "lock must not require an id: {response}"); + assert!(wm.borrow_mut().drain_lock_request(), "the dispatch must have queued a lock request"); + } + + #[test] + fn toggle_floating_dispatch_flips_the_windows_floating_flag() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + let id = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "a")); + id + }; + assert!(!wm.borrow().is_floating(id)); + + let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(format!("{{\"cmd\":\"toggle_floating\",\"id\":{id}}}\n").as_bytes()).unwrap(); + server.poll(&wm); + let _ = read_line(&mut reader); + + assert!(wm.borrow().is_floating(id)); + } + + #[test] + fn toggle_pinned_dispatch_flips_always_on_top() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + let id = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "a")); + id + }; + + let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(format!("{{\"cmd\":\"toggle_pinned\",\"id\":{id}}}\n").as_bytes()).unwrap(); + server.poll(&wm); + let _ = read_line(&mut reader); + + assert!(wm.borrow().window(id).unwrap().always_on_top); + } + + #[test] + fn move_window_dispatch_swaps_with_the_neighbour_and_focuses_the_target_first() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + wm.borrow_mut().set_monitors(vec![srdwm_core::Monitor::new(0, "primary", srdwm_core::Rect::new(0, 0, 1920, 1080))]); + let (a, b) = { + let mut wm = wm.borrow_mut(); + let a = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(a, "a")); + let b = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(b, "b")); + // Set geometry *after* `add_window`, not before - a dynamic- + // layout workspace's own `SmartPlacement` grid overrides + // whatever geometry a freshly constructed `Window` already + // carried in, the same lesson `crates/core/src/manager/ + // tests.rs`'s decoration-mode tests already ran into. + wm.window_mut(a).unwrap().geometry = srdwm_core::Rect::new(0, 0, 400, 300); + wm.window_mut(b).unwrap().geometry = srdwm_core::Rect::new(500, 0, 400, 300); + (a, b) + }; + // Focus `a` first, then ask to move `b` - the dispatch must focus + // `b` itself before swapping, not silently move whatever was + // already focused. + wm.borrow_mut().focus_window(a); + + let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(format!("{{\"cmd\":\"move_window\",\"id\":{b},\"direction\":\"left\"}}\n").as_bytes()).unwrap(); + server.poll(&wm); + let _ = read_line(&mut reader); + + let wm = wm.borrow(); + assert_eq!(wm.window(b).unwrap().geometry.x, 0, "b must have swapped into a's old position"); + assert_eq!(wm.window(a).unwrap().geometry.x, 500, "a must have swapped into b's old position"); + } + + #[test] + fn move_to_workspace_dispatch_moves_the_window_without_switching_the_current_workspace() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + wm.borrow_mut().add_workspace("2", "dynamic"); + let id = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "a")); + id + }; + + let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); + client.write_all(format!("{{\"cmd\":\"move_to_workspace\",\"id\":{id},\"workspace\":1}}\n").as_bytes()).unwrap(); + server.poll(&wm); + let _ = read_line(&mut reader); + + let wm = wm.borrow(); + assert_eq!(wm.window(id).unwrap().workspace, 1); + assert_eq!(wm.current_workspace(), 0, "moving a window to a workspace must not also switch to it"); + } + #[test] fn a_oneshot_clients_request_still_closes_the_connection_as_before() { let dir = tempfile::tempdir().unwrap(); @@ -498,9 +1217,10 @@ mod tests { let wm = Rc::new(RefCell::new(WindowManager::new())); let mut client = UnixStream::connect(&server.path).unwrap(); + let mut reader = std::io::BufReader::new(client.try_clone().unwrap()); client.write_all(b"{\"cmd\":\"clients\"}\n").unwrap(); server.poll(&wm); - let line = read_line(&mut client); + let line = read_line(&mut reader); // The plain, pre-existing shape - no `"event"` field - so // existing one-shot polling consumers (`crates/ctl`) see no change. assert!(!line.contains(r#""event""#)); diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 4be9cc4..5de701f 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -8,9 +8,17 @@ //! pump, macOS's event taps) into the common [`srdwm_core::Event`] queue - //! everything downstream of that is platform-independent. +mod appmenu_registrar; +pub use appmenu_registrar::{AppmenuRegistrarState, RegistrarEvent}; + mod ipc; pub use ipc::IpcServer; +#[cfg(unix)] +mod pam_auth; +#[cfg(unix)] +pub use pam_auth::authenticate; + use srdwm_core::{Monitor, Rect, Window, WindowId}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -146,4 +154,22 @@ pub trait Platform { fn grab_keyboard(&mut self) -> Result<()>; fn ungrab_keyboard(&mut self) -> Result<()>; + + /// The active XKB layout's own human-readable name (e.g. `"English + /// (US)"`) - read once at startup so `WindowManager::keyboard_layout` + /// (surfaced over `srd`, for an AGS peer session's keyboard-layout + /// badge) has a real value before the first cycle, not an empty string + /// until the user cycles once. `Err(Unsupported)` on a backend with no + /// real XKB-backed seat to ask (X11, and the honest-stub Windows/macOS + /// backends) - same convention `grab_keyboard` already uses for "this + /// capability genuinely doesn't exist here" rather than inventing a + /// second one. + fn keyboard_layout(&mut self) -> Result; + + /// Cycles to the next configured XKB layout (wrapping past the last + /// one back to the first) and returns its name. A no-op that returns + /// the same name back is correct, not a bug, when only one layout is + /// configured - there's nothing to cycle *to*, same as every other + /// desktop's layout switcher under the same condition. + fn cycle_keyboard_layout(&mut self) -> Result; } -- cgit v1.2.3