feat: add authenticated IPC and feresectl

This commit is contained in:
Samuel Onoja committed 2026-09-25 03:11:18 +01:00
1 parent 3d0007ba12
commit d6acfa89e7
12 files changed
+774 -2

No files matched your search

Generated
+57
View File
@@ -171,6 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"nix",
"polling", "polling",
"rustix 1.1.5", "rustix 1.1.5",
"slab", "slab",
@@ -416,11 +417,14 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
name = "ferese" name = "ferese"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"calloop 0.14.4",
"ferese-animation", "ferese-animation",
"ferese-core", "ferese-core",
"ferese-ipc",
"ferese-layout", "ferese-layout",
"libc", "libc",
"serde", "serde",
"serde_json",
"smithay", "smithay",
"toml", "toml",
"tracing", "tracing",
@@ -442,10 +446,26 @@ dependencies = [
"ferese-layout", "ferese-layout",
] ]
[[package]]
name = "ferese-ipc"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "ferese-layout" name = "ferese-layout"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "feresectl"
version = "0.1.0"
dependencies = [
"ferese-ipc",
"serde_json",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.13" version = "0.1.13"
@@ -636,6 +656,12 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "jni" name = "jni"
version = "0.22.4" version = "0.22.4"
@@ -863,6 +889,18 @@ dependencies = [
"jni-sys 0.3.1", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1418,6 +1456,19 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@@ -2387,3 +2438,9 @@ dependencies = [
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+4
View File
@@ -1,8 +1,10 @@
[workspace] [workspace]
members = [ members = [
"compositor", "compositor",
"feresectl",
"crates/ferese-animation", "crates/ferese-animation",
"crates/ferese-core", "crates/ferese-core",
"crates/ferese-ipc",
"crates/ferese-layout", "crates/ferese-layout",
] ]
resolver = "3" resolver = "3"
@@ -14,6 +16,7 @@ license = "MIT"
rust-version = "1.87" rust-version = "1.87"
[workspace.dependencies] [workspace.dependencies]
calloop = { version = "0.14.4", features = ["signals"] }
libc = "0.2.176" libc = "0.2.176"
smithay = { version = "0.7.0", default-features = false, features = [ smithay = { version = "0.7.0", default-features = false, features = [
"backend_drm", "backend_drm",
@@ -29,5 +32,6 @@ smithay = { version = "0.7.0", default-features = false, features = [
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
toml = "0.9.7" toml = "0.9.7"
xcursor = "0.3.11" xcursor = "0.3.11"
+15
View File
@@ -121,6 +121,21 @@ fullscreen = false
Rules are finalized on the first toplevel commit, before its buffer is 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. 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 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 hidden unless the session explicitly opts in, because an input method can
request privileged keyboard access. Enable it only when launching a trusted request privileged keyboard access. Enable it only when launching a trusted
+3
View File
@@ -6,12 +6,15 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
calloop.workspace = true
ferese-animation = { path = "../crates/ferese-animation" } ferese-animation = { path = "../crates/ferese-animation" }
ferese-core = { path = "../crates/ferese-core" } ferese-core = { path = "../crates/ferese-core" }
ferese-ipc = { path = "../crates/ferese-ipc" }
ferese-layout = { path = "../crates/ferese-layout" } ferese-layout = { path = "../crates/ferese-layout" }
libc.workspace = true libc.workspace = true
smithay.workspace = true smithay.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true
toml.workspace = true toml.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
+392
View File
@@ -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<Response>,
}
#[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<IpcSocketGuard, Box<dyn std::error::Error>> {
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<IpcCall>, channel::Channel<IpcCall>) =
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<IpcCall>) {
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<IpcCall>) {
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<Value, CommandError> {
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::<Vec<_>>();
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::<Vec<_>>();
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<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
fn direction_arg(args: &Value) -> Result<Direction, CommandError> {
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<u32, CommandError> {
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<PathBuf, io::Error> {
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<UnixListener, io::Error> {
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<u32, io::Error> {
let mut credentials = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut length = std::mem::size_of::<libc::ucred>() 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());
}
}
+9
View File
@@ -6,12 +6,14 @@ mod cursor;
mod grabs; mod grabs;
mod handlers; mod handlers;
mod input; mod input;
mod ipc;
mod state; mod state;
mod window_rules; mod window_rules;
mod winit; mod winit;
use std::{error::Error, io, process::Command}; use std::{error::Error, io, process::Command};
use calloop::signals::{Signal, Signals};
use smithay::reexports::{calloop::EventLoop, wayland_server::Display}; use smithay::reexports::{calloop::EventLoop, wayland_server::Display};
pub use state::{Ferese, RuntimeConfig}; pub use state::{Ferese, RuntimeConfig};
use tracing::{info, warn}; use tracing::{info, warn};
@@ -29,6 +31,13 @@ fn main() -> Result<(), Box<dyn Error>> {
let bindings = config.bindings(&input_settings)?; let bindings = config.bindings(&input_settings)?;
let window_rules = config.window_rules()?; let window_rules = config.window_rules()?;
let mut event_loop = EventLoop::try_new()?; 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 display = Display::new()?;
let runtime = RuntimeConfig { let runtime = RuntimeConfig {
layout_mode: config.layout_mode(), layout_mode: config.layout_mode(),
+10 -2
View File
@@ -112,6 +112,7 @@ pub struct Ferese {
pub idle_inhibitors: HashMap<WlSurface, usize>, pub idle_inhibitors: HashMap<WlSurface, usize>,
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>, pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
pub direct_backend: Option<crate::backends::direct::DirectBackendState>, pub direct_backend: Option<crate::backends::direct::DirectBackendState>,
_ipc_socket: Option<crate::ipc::IpcSocketGuard>,
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>, pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
pub(crate) pending_screencopies: Vec<crate::handlers::screencopy::PendingScreencopy>, pub(crate) pending_screencopies: Vec<crate::handlers::screencopy::PendingScreencopy>,
next_window_id: u64, next_window_id: u64,
@@ -260,6 +261,10 @@ impl Ferese {
.find_map(|(output, id)| (*id == focused).then_some(output)) .find_map(|(output, id)| (*id == focused).then_some(output))
} }
pub(crate) fn output_id(&self, output: &Output) -> Option<OutputId> {
self.output_ids.get(output).copied()
}
pub(crate) fn focus_output_at(&mut self, position: Point<f64, Logical>) { pub(crate) fn focus_output_at(&mut self, position: Point<f64, Logical>) {
let output = self.space.outputs().find_map(|output| { let output = self.space.outputs().find_map(|output| {
let geometry = self.space.output_geometry(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 default_cursor = crate::cursor::load_named_cursor(&cursor_theme, CursorIcon::Default);
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]); let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
Ok(Self { let mut state = Self {
start_time, start_time,
socket_name, socket_name,
display_handle, display_handle,
@@ -405,6 +410,7 @@ impl Ferese {
idle_inhibitors: HashMap::new(), idle_inhibitors: HashMap::new(),
active_shortcuts_inhibitor: None, active_shortcuts_inhibitor: None,
direct_backend: None, direct_backend: None,
_ipc_socket: None,
pending_dmabuf_imports: Vec::new(), pending_dmabuf_imports: Vec::new(),
pending_screencopies: Vec::new(), pending_screencopies: Vec::new(),
next_window_id: 1, next_window_id: 1,
@@ -437,7 +443,9 @@ impl Ferese {
xdg_foreign_state, xdg_foreign_state,
xdg_shell_state, xdg_shell_state,
xdg_toplevel_icon_manager, xdg_toplevel_icon_manager,
}) };
state._ipc_socket = Some(crate::ipc::init(event_loop)?);
Ok(state)
} }
fn init_wayland_listener( fn init_wayland_listener(
+4
View File
@@ -445,6 +445,10 @@ impl WorkspaceSet {
self.workspaces.get_mut(&id) self.workspaces.get_mut(&id)
} }
pub fn iter(&self) -> impl Iterator<Item = &Workspace> {
self.workspaces.values()
}
pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> { pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> {
self.window_workspaces.get(&window).copied() self.window_workspaces.get(&window).copied()
} }
+10
View File
@@ -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
+152
View File
@@ -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<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ResponseError>,
}
#[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<String>, message: impl Into<String>) -> 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<io::Error> for FrameError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
pub fn read_frame<T: for<'de> Deserialize<'de>>(reader: &mut impl Read) -> Result<T, FrameError> {
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<T: Serialize>(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::<Request>(&mut size.as_slice()).unwrap_err();
assert!(matches!(error, FrameError::Oversized(_)));
}
}
+10
View File
@@ -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
+108
View File
@@ -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<dyn Error>> {
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<Item = String>) -> Result<(String, Value), String> {
let mut args = args.into_iter();
let command = args.next().ok_or_else(usage)?;
let positional = args.collect::<Vec<_>>();
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::<u32>()
.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<PathBuf, io::Error> {
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 <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-fullscreen|close|get-focused-window|get-workspaces|get-outputs>".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());
}
}