feat: add authenticated IPC and feresectl
This commit is contained in:
1 parent
3d0007ba12
commit
d6acfa89e7
12 files changed
+774
-2
No files matched your search
Generated
+57
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Error>> {
|
||||
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(),
|
||||
|
||||
+10
-2
@@ -112,6 +112,7 @@ pub struct Ferese {
|
||||
pub idle_inhibitors: HashMap<WlSurface, usize>,
|
||||
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
|
||||
pub direct_backend: Option<crate::backends::direct::DirectBackendState>,
|
||||
_ipc_socket: Option<crate::ipc::IpcSocketGuard>,
|
||||
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
|
||||
pub(crate) pending_screencopies: Vec<crate::handlers::screencopy::PendingScreencopy>,
|
||||
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<OutputId> {
|
||||
self.output_ids.get(output).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn focus_output_at(&mut self, position: Point<f64, Logical>) {
|
||||
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(
|
||||
|
||||
@@ -445,6 +445,10 @@ impl WorkspaceSet {
|
||||
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> {
|
||||
self.window_workspaces.get(&window).copied()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user