From d6acfa89e7aed6bf8aecab044627b251f2373676 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 03:11:18 +0100 Subject: [PATCH] feat: add authenticated IPC and feresectl --- Cargo.lock | 57 ++++ Cargo.toml | 4 + README.md | 15 ++ compositor/Cargo.toml | 3 + compositor/src/ipc.rs | 392 ++++++++++++++++++++++++++++ compositor/src/main.rs | 9 + compositor/src/state.rs | 12 +- crates/ferese-core/src/workspace.rs | 4 + crates/ferese-ipc/Cargo.toml | 10 + crates/ferese-ipc/src/lib.rs | 152 +++++++++++ feresectl/Cargo.toml | 10 + feresectl/src/main.rs | 108 ++++++++ 12 files changed, 774 insertions(+), 2 deletions(-) create mode 100644 compositor/src/ipc.rs create mode 100644 crates/ferese-ipc/Cargo.toml create mode 100644 crates/ferese-ipc/src/lib.rs create mode 100644 feresectl/Cargo.toml create mode 100644 feresectl/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 771d81a..b37e619 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.13.2", + "nix", "polling", "rustix 1.1.5", "slab", @@ -416,11 +417,14 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" name = "ferese" version = "0.1.0" dependencies = [ + "calloop 0.14.4", "ferese-animation", "ferese-core", + "ferese-ipc", "ferese-layout", "libc", "serde", + "serde_json", "smithay", "toml", "tracing", @@ -442,10 +446,26 @@ dependencies = [ "ferese-layout", ] +[[package]] +name = "ferese-ipc" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "ferese-layout" version = "0.1.0" +[[package]] +name = "feresectl" +version = "0.1.0" +dependencies = [ + "ferese-ipc", + "serde_json", +] + [[package]] name = "find-msvc-tools" version = "0.1.13" @@ -636,6 +656,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.22.4" @@ -863,6 +889,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1418,6 +1456,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2387,3 +2438,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index bc691f8..3e1b0eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,10 @@ [workspace] members = [ "compositor", + "feresectl", "crates/ferese-animation", "crates/ferese-core", + "crates/ferese-ipc", "crates/ferese-layout", ] resolver = "3" @@ -14,6 +16,7 @@ license = "MIT" rust-version = "1.87" [workspace.dependencies] +calloop = { version = "0.14.4", features = ["signals"] } libc = "0.2.176" smithay = { version = "0.7.0", default-features = false, features = [ "backend_drm", @@ -29,5 +32,6 @@ smithay = { version = "0.7.0", default-features = false, features = [ tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" toml = "0.9.7" xcursor = "0.3.11" diff --git a/README.md b/README.md index 153c381..3d59a58 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,21 @@ fullscreen = false Rules are finalized on the first toplevel commit, before its buffer is presented, and are not re-applied when an application later changes its title. +`feresectl` controls the running compositor through the same-user socket at +`$XDG_RUNTIME_DIR/ferese/control.sock`. For example: + +```bash +cargo run -p feresectl -- focus left +cargo run -p feresectl -- workspace 2 +cargo run -p feresectl -- get-workspaces +``` + +The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, +`toggle-floating`, `toggle-fullscreen`, `close`, `get-focused-window`, and +`get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The +runtime directory and socket are restricted to modes `0700` and `0600`, and the +server rejects peers whose effective UID differs from the compositor's. + Text-input-v3 is available to applications by default. Input-method-v2 is hidden unless the session explicitly opts in, because an input method can request privileged keyboard access. Enable it only when launching a trusted diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index e662f97..2f7145d 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -6,12 +6,15 @@ license.workspace = true rust-version.workspace = true [dependencies] +calloop.workspace = true ferese-animation = { path = "../crates/ferese-animation" } ferese-core = { path = "../crates/ferese-core" } +ferese-ipc = { path = "../crates/ferese-ipc" } ferese-layout = { path = "../crates/ferese-layout" } libc.workspace = true smithay.workspace = true serde.workspace = true +serde_json.workspace = true toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs new file mode 100644 index 0000000..0bc04e0 --- /dev/null +++ b/compositor/src/ipc.rs @@ -0,0 +1,392 @@ +use std::env; +use std::fs; +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{SyncSender, sync_channel}; +use std::thread; + +use ferese_core::LayoutMode; +use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; +use ferese_layout::Direction; +use serde_json::{Value, json}; +use smithay::reexports::calloop::{EventLoop, channel}; + +use crate::Ferese; + +const REQUEST_QUEUE_CAPACITY: usize = 128; + +#[derive(Debug)] +struct IpcCall { + request: Request, + response: SyncSender, +} + +#[derive(Debug)] +pub(crate) struct IpcSocketGuard { + path: PathBuf, + device: u64, + inode: u64, +} + +impl Drop for IpcSocketGuard { + fn drop(&mut self) { + if self.path.symlink_metadata().is_ok_and(|metadata| { + metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode + }) { + let _ = fs::remove_file(&self.path); + } + } +} + +pub(crate) fn init( + event_loop: &mut EventLoop<'static, Ferese>, +) -> Result> { + let path = socket_path()?; + prepare_parent(&path)?; + let listener = bind_listener(&path)?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + let metadata = fs::symlink_metadata(&path)?; + let guard = IpcSocketGuard { + path: path.clone(), + device: metadata.dev(), + inode: metadata.ino(), + }; + + let (sender, receiver): (channel::SyncSender, channel::Channel) = + channel::sync_channel(REQUEST_QUEUE_CAPACITY); + event_loop + .handle() + .insert_source(receiver, |event, _, state| { + if let channel::Event::Msg(call) = event { + let response = state.handle_ipc_request(call.request); + let _ = call.response.send(response); + } + })?; + + thread::Builder::new() + .name("ferese-ipc-listener".to_owned()) + .spawn(move || accept_connections(listener, sender))?; + + tracing::info!(path = %path.display(), "Ferese IPC is accepting connections"); + Ok(guard) +} + +fn accept_connections(listener: UnixListener, sender: channel::SyncSender) { + loop { + let (stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => { + tracing::error!(%error, "IPC listener stopped"); + return; + } + }; + + match peer_uid(&stream) { + Ok(uid) if uid == effective_uid() => {} + Ok(uid) => { + tracing::warn!(uid, "rejected IPC connection from a different user"); + continue; + } + Err(error) => { + tracing::warn!(%error, "could not authenticate IPC peer"); + continue; + } + } + + let sender = sender.clone(); + if let Err(error) = thread::Builder::new() + .name("ferese-ipc-client".to_owned()) + .spawn(move || serve_connection(stream, sender)) + { + tracing::warn!(%error, "could not start IPC connection worker"); + } + } +} + +fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender) { + loop { + let request = match read_frame(&mut stream) { + Ok(request) => request, + Err(ferese_ipc::FrameError::Io(error)) + if matches!( + error.kind(), + io::ErrorKind::UnexpectedEof + | io::ErrorKind::ConnectionReset + | io::ErrorKind::BrokenPipe + ) => + { + return; + } + Err(error) => { + tracing::warn!(%error, "closing invalid IPC connection"); + return; + } + }; + let (response, receiver) = sync_channel(1); + let call = IpcCall { request, response }; + if sender.try_send(call).is_err() { + tracing::warn!("disconnecting IPC client because the request queue is full"); + return; + } + let Ok(response) = receiver.recv() else { + return; + }; + if let Err(error) = write_frame(&mut stream, &response) { + tracing::debug!(%error, "IPC client disconnected before receiving its response"); + return; + } + } +} + +impl Ferese { + fn handle_ipc_request(&mut self, request: Request) -> Response { + if request.version != VERSION { + return Response::error( + request.id, + "unsupported_version", + format!("supported version is {VERSION}"), + ); + } + if request.kind != "command" { + return Response::error(request.id, "invalid_type", "expected type \"command\""); + } + + match self.dispatch_ipc_command(&request.command, &request.args) { + Ok(result) => Response::success(request.id, result), + Err(error) => Response::error(request.id, error.code, error.message), + } + } + + fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result { + match command { + "focus" => self.focus_direction(direction_arg(args)?), + "move" => self.move_direction(direction_arg(args)?), + "resize" => self.resize_direction(direction_arg(args)?), + "workspace" => self.switch_workspace(workspace_arg(args)?), + "move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?), + "toggle-floating" => self.toggle_focused_floating(), + "toggle-fullscreen" => self.toggle_focused_fullscreen(), + "close" => self.close_focused_window(), + "get-focused-window" => return Ok(self.focused_window_json()), + "get-workspaces" => return Ok(self.workspaces_json()), + "get-outputs" => return Ok(self.outputs_json()), + _ => { + return Err(CommandError::new( + "unknown_command", + format!("unknown command {command:?}"), + )); + } + } + + Ok(json!({})) + } + + fn focused_window_json(&self) -> Value { + self.focused_window + .map(|window| json!({ "id": window.0 })) + .unwrap_or(Value::Null) + } + + fn workspaces_json(&self) -> Value { + let active = self.workspaces.active_id(); + let mut workspaces = self + .workspaces + .iter() + .map(|workspace| { + let mode = match workspace.layout.mode() { + LayoutMode::Scrolling => "scrolling", + LayoutMode::Tree => "tree", + }; + json!({ + "id": workspace.id.0, + "name": workspace.name, + "active": workspace.id == active, + "layout": mode, + "focused_window": workspace.last_focused.map(|window| window.0), + "fullscreen_window": workspace.fullscreen.map(|window| window.0), + }) + }) + .collect::>(); + workspaces.sort_by_key(|workspace| workspace["id"].as_u64()); + Value::Array(workspaces) + } + + fn outputs_json(&self) -> Value { + let focused = self.output_workspaces.focused_output(); + let mut outputs = self + .space + .outputs() + .filter_map(|output| { + let id = self.output_id(output)?; + let geometry = self.space.output_geometry(output)?; + let workspace = self.output_workspaces.active_workspace(id)?; + Some(json!({ + "id": id.0, + "name": output.name(), + "focused": Some(id) == focused, + "workspace": workspace.0, + "x": geometry.loc.x, + "y": geometry.loc.y, + "width": geometry.size.w, + "height": geometry.size.h, + })) + }) + .collect::>(); + outputs.sort_by_key(|output| output["id"].as_u64()); + Value::Array(outputs) + } +} + +#[derive(Debug)] +struct CommandError { + code: &'static str, + message: String, +} + +impl CommandError { + fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +fn direction_arg(args: &Value) -> Result { + match args.get("direction").and_then(Value::as_str) { + Some("left") => Ok(Direction::Left), + Some("right") => Ok(Direction::Right), + Some("up") => Ok(Direction::Up), + Some("down") => Ok(Direction::Down), + _ => Err(CommandError::new( + "invalid_argument", + "direction must be left, right, up, or down", + )), + } +} + +fn workspace_arg(args: &Value) -> Result { + args.get("index") + .and_then(Value::as_u64) + .and_then(|index| u32::try_from(index).ok()) + .filter(|index| *index > 0) + .ok_or_else(|| { + CommandError::new( + "invalid_argument", + "index must be a positive 32-bit integer", + ) + }) +} + +fn socket_path() -> Result { + env::var_os("XDG_RUNTIME_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|directory| directory.join("ferese/control.sock")) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set")) +} + +fn prepare_parent(socket: &Path) -> Result<(), io::Error> { + let parent = socket + .parent() + .expect("the Ferese control socket always has a parent"); + match fs::symlink_metadata(parent) { + Ok(metadata) => { + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("{} is not a real directory", parent.display()), + )); + } + if metadata.uid() != effective_uid() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{} is owned by another user", parent.display()), + )); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir(parent)?, + Err(error) => return Err(error), + } + + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) +} + +fn bind_listener(path: &Path) -> Result { + match UnixListener::bind(path) { + Ok(listener) => Ok(listener), + Err(error) if error.kind() == io::ErrorKind::AddrInUse => { + if UnixStream::connect(path).is_ok() { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + "another Ferese IPC server is already running", + )); + } + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_socket() || metadata.uid() != effective_uid() { + return Err(error); + } + fs::remove_file(path)?; + UnixListener::bind(path) + } + Err(error) => Err(error), + } +} + +fn peer_uid(stream: &UnixStream) -> Result { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = std::mem::size_of::() as libc::socklen_t; + + // SAFETY: `credentials` and `length` point to initialized, correctly sized + // storage for Linux SO_PEERCRED, and the stream owns a live file descriptor. + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&raw mut credentials).cast(), + &raw mut length, + ) + }; + if result == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(credentials.uid) + } +} + +fn effective_uid() -> u32 { + // SAFETY: `geteuid` has no preconditions and cannot fail. + unsafe { libc::geteuid() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_direction_arguments() { + assert_eq!( + direction_arg(&json!({ "direction": "left" })).unwrap(), + Direction::Left + ); + assert!(direction_arg(&json!({ "direction": "diagonal" })).is_err()); + } + + #[test] + fn validates_workspace_arguments() { + assert_eq!(workspace_arg(&json!({ "index": 9 })).unwrap(), 9); + assert!(workspace_arg(&json!({ "index": 0 })).is_err()); + assert!(workspace_arg(&json!({ "index": -1 })).is_err()); + } +} diff --git a/compositor/src/main.rs b/compositor/src/main.rs index aab9ff6..3274857 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -6,12 +6,14 @@ mod cursor; mod grabs; mod handlers; mod input; +mod ipc; mod state; mod window_rules; mod winit; use std::{error::Error, io, process::Command}; +use calloop::signals::{Signal, Signals}; use smithay::reexports::{calloop::EventLoop, wayland_server::Display}; pub use state::{Ferese, RuntimeConfig}; use tracing::{info, warn}; @@ -29,6 +31,13 @@ fn main() -> Result<(), Box> { let bindings = config.bindings(&input_settings)?; let window_rules = config.window_rules()?; let mut event_loop = EventLoop::try_new()?; + let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?; + event_loop + .handle() + .insert_source(signals, |event, _, state: &mut Ferese| { + info!(signal = ?event.signal(), "stopping Ferese"); + state.loop_signal.stop(); + })?; let display = Display::new()?; let runtime = RuntimeConfig { layout_mode: config.layout_mode(), diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 38a69f9..c096067 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -112,6 +112,7 @@ pub struct Ferese { pub idle_inhibitors: HashMap, pub active_shortcuts_inhibitor: Option, pub direct_backend: Option, + _ipc_socket: Option, pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>, pub(crate) pending_screencopies: Vec, next_window_id: u64, @@ -260,6 +261,10 @@ impl Ferese { .find_map(|(output, id)| (*id == focused).then_some(output)) } + pub(crate) fn output_id(&self, output: &Output) -> Option { + self.output_ids.get(output).copied() + } + pub(crate) fn focus_output_at(&mut self, position: Point) { let output = self.space.outputs().find_map(|output| { let geometry = self.space.output_geometry(output)?; @@ -369,7 +374,7 @@ impl Ferese { let default_cursor = crate::cursor::load_named_cursor(&cursor_theme, CursorIcon::Default); let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]); - Ok(Self { + let mut state = Self { start_time, socket_name, display_handle, @@ -405,6 +410,7 @@ impl Ferese { idle_inhibitors: HashMap::new(), active_shortcuts_inhibitor: None, direct_backend: None, + _ipc_socket: None, pending_dmabuf_imports: Vec::new(), pending_screencopies: Vec::new(), next_window_id: 1, @@ -437,7 +443,9 @@ impl Ferese { xdg_foreign_state, xdg_shell_state, xdg_toplevel_icon_manager, - }) + }; + state._ipc_socket = Some(crate::ipc::init(event_loop)?); + Ok(state) } fn init_wayland_listener( diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 3512ee3..dc7bf0d 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -445,6 +445,10 @@ impl WorkspaceSet { self.workspaces.get_mut(&id) } + pub fn iter(&self) -> impl Iterator { + self.workspaces.values() + } + pub fn workspace_for_window(&self, window: WindowId) -> Option { self.window_workspaces.get(&window).copied() } diff --git a/crates/ferese-ipc/Cargo.toml b/crates/ferese-ipc/Cargo.toml new file mode 100644 index 0000000..ae357f3 --- /dev/null +++ b/crates/ferese-ipc/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ferese-ipc" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/ferese-ipc/src/lib.rs b/crates/ferese-ipc/src/lib.rs new file mode 100644 index 0000000..b3fb71b --- /dev/null +++ b/crates/ferese-ipc/src/lib.rs @@ -0,0 +1,152 @@ +use std::error::Error; +use std::fmt; +use std::io::{self, Read, Write}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const VERSION: u32 = 1; +pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Request { + pub version: u32, + pub id: u64, + #[serde(rename = "type")] + pub kind: String, + pub command: String, + #[serde(default)] + pub args: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Response { + pub version: u32, + pub id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ResponseError { + pub code: String, + pub message: String, +} + +impl Response { + pub fn success(id: u64, result: Value) -> Self { + Self { + version: VERSION, + id, + result: Some(result), + error: None, + } + } + + pub fn error(id: u64, code: impl Into, message: impl Into) -> Self { + Self { + version: VERSION, + id, + result: None, + error: Some(ResponseError { + code: code.into(), + message: message.into(), + }), + } + } +} + +#[derive(Debug)] +pub enum FrameError { + Io(io::Error), + Oversized(usize), + InvalidJson(serde_json::Error), +} + +impl fmt::Display for FrameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => error.fmt(formatter), + Self::Oversized(size) => write!( + formatter, + "IPC payload is {size} bytes; maximum is {MAX_PAYLOAD_SIZE}" + ), + Self::InvalidJson(error) => write!(formatter, "invalid IPC JSON: {error}"), + } + } +} + +impl Error for FrameError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::InvalidJson(error) => Some(error), + Self::Oversized(_) => None, + } + } +} + +impl From for FrameError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +pub fn read_frame Deserialize<'de>>(reader: &mut impl Read) -> Result { + let mut header = [0_u8; 4]; + reader.read_exact(&mut header)?; + let size = u32::from_be_bytes(header) as usize; + if size > MAX_PAYLOAD_SIZE { + return Err(FrameError::Oversized(size)); + } + + let mut payload = vec![0; size]; + reader.read_exact(&mut payload)?; + serde_json::from_slice(&payload).map_err(FrameError::InvalidJson) +} + +pub fn write_frame(writer: &mut impl Write, value: &T) -> Result<(), FrameError> { + let payload = serde_json::to_vec(value).map_err(FrameError::InvalidJson)?; + if payload.len() > MAX_PAYLOAD_SIZE { + return Err(FrameError::Oversized(payload.len())); + } + + writer.write_all(&(payload.len() as u32).to_be_bytes())?; + writer.write_all(&payload)?; + writer.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn framed_json_round_trips() { + let request = Request { + version: VERSION, + id: 42, + kind: "command".to_owned(), + command: "focus".to_owned(), + args: serde_json::json!({ "direction": "left" }), + }; + let mut bytes = Vec::new(); + write_frame(&mut bytes, &request).unwrap(); + let decoded: Request = read_frame(&mut bytes.as_slice()).unwrap(); + + assert_eq!(decoded.version, VERSION); + assert_eq!(decoded.id, 42); + assert_eq!(decoded.command, "focus"); + assert_eq!(decoded.args["direction"], "left"); + } + + #[test] + fn rejects_oversized_frames_before_allocating_payload() { + let size = (MAX_PAYLOAD_SIZE as u32 + 1).to_be_bytes(); + let error = read_frame::(&mut size.as_slice()).unwrap_err(); + + assert!(matches!(error, FrameError::Oversized(_))); + } +} diff --git a/feresectl/Cargo.toml b/feresectl/Cargo.toml new file mode 100644 index 0000000..21a3574 --- /dev/null +++ b/feresectl/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "feresectl" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +ferese-ipc = { path = "../crates/ferese-ipc" } +serde_json.workspace = true diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs new file mode 100644 index 0000000..6f43d56 --- /dev/null +++ b/feresectl/src/main.rs @@ -0,0 +1,108 @@ +use std::env; +use std::error::Error; +use std::io; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; +use serde_json::{Value, json}; + +fn main() -> Result<(), Box> { + let (command, args) = parse_args(env::args().skip(1))?; + let request = Request { + version: VERSION, + id: 1, + kind: "command".to_owned(), + command, + args, + }; + let mut stream = UnixStream::connect(socket_path()?)?; + + write_frame(&mut stream, &request)?; + let response: Response = read_frame(&mut stream)?; + if let Some(error) = response.error { + return Err(format!("{}: {}", error.code, error.message).into()); + } + + println!( + "{}", + serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))? + ); + Ok(()) +} + +fn parse_args(args: impl IntoIterator) -> Result<(String, Value), String> { + let mut args = args.into_iter(); + let command = args.next().ok_or_else(usage)?; + let positional = args.collect::>(); + let payload = match command.as_str() { + "focus" | "move" | "resize" => { + exactly_one(&command, &positional, "direction")?; + json!({ "direction": positional[0] }) + } + "workspace" | "move-to-workspace" => { + exactly_one(&command, &positional, "index")?; + let index = positional[0] + .parse::() + .map_err(|_| format!("{} requires a positive workspace index", command))?; + json!({ "index": index }) + } + "toggle-floating" | "toggle-fullscreen" | "close" | "get-focused-window" + | "get-workspaces" | "get-outputs" => { + if !positional.is_empty() { + return Err(format!("{command} does not accept arguments")); + } + json!({}) + } + _ => return Err(format!("unknown command {command:?}\n{}", usage())), + }; + + Ok((command, payload)) +} + +fn exactly_one(command: &str, args: &[String], name: &str) -> Result<(), String> { + if args.len() == 1 { + Ok(()) + } else { + Err(format!("{command} requires exactly one {name}")) + } +} + +fn socket_path() -> Result { + env::var_os("XDG_RUNTIME_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|directory| directory.join("ferese/control.sock")) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set")) +} + +fn usage() -> String { + "usage: feresectl \n feresectl \n feresectl ".to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_direction_and_workspace_commands() { + assert_eq!( + parse_args(["focus".to_owned(), "left".to_owned()]) + .unwrap() + .1, + json!({ "direction": "left" }) + ); + assert_eq!( + parse_args(["workspace".to_owned(), "7".to_owned()]) + .unwrap() + .1, + json!({ "index": 7 }) + ); + } + + #[test] + fn rejects_unknown_or_missing_arguments() { + assert!(parse_args(["focus".to_owned()]).is_err()); + assert!(parse_args(["reload-config".to_owned()]).is_err()); + } +}