From 97026a0db76b37c074f63c38fe95282886379618 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 7 Oct 2026 07:39:27 +0100 Subject: [PATCH] Modernize feresectl and add bounded IPC event streaming --- Cargo.lock | 111 ++++++ compositor/src/ipc.rs | 166 ++++++++- compositor/src/ipc_events.rs | 347 ++++++++++++++++++ compositor/src/main.rs | 3 + compositor/src/reload.rs | 1 + compositor/src/session_lock.rs | 13 + compositor/src/session_lock/tests.rs | 36 ++ compositor/src/state.rs | 2 + crates/ferese-ipc/src/events.rs | 134 +++++++ crates/ferese-ipc/src/lib.rs | 2 + crates/ferese-ipc/src/socket.rs | 49 +++ crates/ferese-ipc/src/theme.rs | 11 +- docs/README.md | 1 + docs/configuration.md | 2 +- docs/ipc.md | 125 +++++++ docs/native-soak-testing.md | 2 +- feresectl/Cargo.toml | 5 + feresectl/src/cli.rs | 285 ++++++++++++++ feresectl/src/main.rs | 328 +++++------------ feresectl/src/output.rs | 97 +++++ feresectl/tests/cli.rs | 165 +++++++++ packaging/config.kdl | 2 +- packaging/ferese-screenshot | 2 +- scripts/soak-native.sh | 2 +- scripts/tests/test_blurred_popup_isolated.py | 2 +- scripts/tests/test_floating_isolated.py | 2 +- .../tests/test_floating_placement_isolated.py | 2 +- scripts/tests/test_focus_history_isolated.py | 2 +- .../tests/test_idle_inhibition_isolated.py | 2 +- scripts/tests/test_media_isolated.py | 2 +- .../tests/test_overview_damage_isolated.py | 2 +- .../tests/test_overview_previews_isolated.py | 2 +- scripts/tests/test_presentation_isolated.py | 2 +- .../test_resize_dependencies_isolated.py | 2 +- scripts/tests/test_screenshot.py | 2 +- scripts/tests/test_theme_isolated.py | 2 +- .../tests/test_unmapped_toplevel_isolated.py | 6 +- scripts/tests/test_window_capture_isolated.py | 2 +- .../tests/test_workspace_history_isolated.py | 2 +- 39 files changed, 1671 insertions(+), 254 deletions(-) create mode 100644 compositor/src/ipc_events.rs create mode 100644 crates/ferese-ipc/src/events.rs create mode 100644 crates/ferese-ipc/src/socket.rs create mode 100644 docs/ipc.md create mode 100644 feresectl/src/cli.rs create mode 100644 feresectl/src/output.rs create mode 100644 feresectl/tests/cli.rs diff --git a/Cargo.lock b/Cargo.lock index a3e15cc..5936e8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,12 +191,56 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "appendlist" version = "1.4.0" @@ -826,6 +870,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -911,6 +995,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1854,10 +1944,13 @@ dependencies = [ name = "feresectl" version = "0.1.0" dependencies = [ + "clap", "ferese-config", "ferese-ipc", "libc", + "serde", "serde_json", + "tempfile", ] [[package]] @@ -3093,6 +3186,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -4280,6 +4379,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "option-ext" version = "0.2.0" @@ -6277,6 +6382,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 9dfdbdd..2386ed6 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -43,6 +43,7 @@ struct IpcCall { owner: u64, request: Request, response: SyncSender, + events: Option>>, } #[derive(Debug)] @@ -108,6 +109,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result call, IpcEvent::Closed(owner) => { + state.ipc_events.remove(owner); for request in state.screenshot.terminate_owner(owner) { state .pending_screencopies @@ -126,7 +128,25 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result Response::success(call.request.id, json!({"version": ferese_ipc::events::VERSION})), + Err(error) => Response::error(call.request.id, error.code, error.message), + }; + let _ = call.response.try_send(response); + } else if call.request.command.starts_with("input-capture-") && !call.native_portal { let _ = call.response.try_send(Response::error( call.request.id, "access_denied", @@ -449,6 +469,8 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender>) { + loop { + match events.recv_timeout(Duration::from_millis(250)) { + Ok(event) => { + if write_frame(stream, event.as_ref()).is_err() { + return; + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + let mut byte = 0_u8; + // SAFETY: the stream owns its descriptor and byte is writable. + let result = unsafe { + libc::recv( + stream.as_raw_fd(), + (&raw mut byte).cast(), + 1, + libc::MSG_PEEK | libc::MSG_DONTWAIT, + ) + }; + if result >= 0 { + return; + } + if !matches!( + io::Error::last_os_error().kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) { + return; + } + } + } + } +} + fn u32_arg(args: &Value, key: &str) -> Result { args[key] .as_u64() @@ -1111,6 +1175,58 @@ impl Ferese { } } + pub(crate) fn ipc_event_snapshot(&self) -> ferese_ipc::events::Snapshot { + let locked = self.session_lock.active(); + let mut outputs = self.outputs_json(); + if locked { + for output in outputs.as_array_mut().into_iter().flatten() { + if let Some(fields) = output.as_object_mut() { + fields.remove("workspace"); + fields.remove("focused"); + } + } + } + let windows = if locked { + json!([]) + } else { + Value::Array(self.windows.ordered_ids().filter_map(|id| { + let record = self.windows.record(id)?; + let workspace = self.workspaces.workspace_for_window(id)?; + Some(json!({ + "id": id.0, "app_id": record.app_id, "title": record.title, + "focused": self.focused_window == Some(id), "workspace": workspace.0, + "floating": matches!(self.workspaces.placement(id), Some(ferese_core::WindowPlacement::Floating { .. })), + "fullscreen": self.workspaces.workspace(workspace).is_some_and(|workspace| workspace.fullscreen == Some(id)), + })) + }).collect()) + }; + let live = &self.theme_engine.live; + let theme = json!({ + "mode": live.mode, "theme": live.theme, "families": live.families, + "warnings": live.warnings, "error": live.error, "fallback_note": live.fallback_note, + }); + ferese_ipc::events::Snapshot { + outputs, + workspaces: if locked { json!([]) } else { self.workspaces_json() }, + windows, + focus: if locked { + Value::Null + } else { + json!({ + "window": self.focused_window.map(|id| id.0), + "output": self.output_workspaces.focused_output().map(|id| id.0), + "workspace": self.output_workspaces.focused_output().and_then(|id| self.output_workspaces.active_workspace(id)).map(|id| id.0), + }) + }, + config: ferese_ipc::events::ConfigState { + revision: self.ipc_events.config_revision, + error: self.theme_engine.live.error.clone(), + }, + theme, + lock: self.session_lock.event_state(), + } + } + fn focused_window_json(&self) -> Value { self.focused_window .map(|window| json!({ "id": window.0 })) @@ -1743,6 +1859,54 @@ mod tests { assert!(try_acquire_connection(&active).is_some()); } + #[test] + fn idle_event_stream_disconnect_is_detected_without_an_event() { + let (client, mut server) = UnixStream::pair().unwrap(); + let (_sender, receiver) = sync_channel(1); + let (finished, completion) = sync_channel(1); + let worker = thread::spawn(move || { + relay_events(&mut server, receiver); + finished.send(()).unwrap(); + }); + drop(client); + completion.recv_timeout(Duration::from_secs(2)).unwrap(); + worker.join().unwrap(); + } + + #[test] + fn stalled_event_socket_write_expires_on_the_worker() { + let (_client, mut server) = UnixStream::pair().unwrap(); + server.set_write_timeout(Some(Duration::from_millis(50))).unwrap(); + let (sender, receiver) = sync_channel(1); + let (finished, completion) = sync_channel(1); + sender + .send(Arc::new(ferese_ipc::events::Event { + version: ferese_ipc::events::VERSION, + generation: 1, + last: true, + change: ferese_ipc::events::Change::WindowsChanged { + windows: json!([{"title": "x".repeat(900_000)}]), + }, + })) + .unwrap(); + let worker = thread::spawn(move || { + relay_events(&mut server, receiver); + finished.send(()).unwrap(); + }); + completion.recv_timeout(Duration::from_secs(2)).unwrap(); + worker.join().unwrap(); + assert!( + sender + .try_send(Arc::new(ferese_ipc::events::Event { + version: ferese_ipc::events::VERSION, + generation: 2, + last: true, + change: ferese_ipc::events::Change::FocusChanged { focus: json!(null) }, + })) + .is_err() + ); + } + fn unique_test_directory(label: &str) -> PathBuf { let nonce = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/compositor/src/ipc_events.rs b/compositor/src/ipc_events.rs new file mode 100644 index 0000000..1f0bd49 --- /dev/null +++ b/compositor/src/ipc_events.rs @@ -0,0 +1,347 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::mpsc::SyncSender; + +use ferese_ipc::events::{Change, Event, Snapshot, VERSION}; + +pub(crate) const QUEUE_CAPACITY: usize = 32; +const MAX_SUBSCRIBERS: usize = 16; + +struct Subscriber { + sender: SyncSender>, + initial: bool, +} + +#[derive(Default)] +pub(crate) struct Subscribers { + subscribers: HashMap, + previous: Option, + generation: u64, + pub(crate) config_revision: u64, +} + +impl Subscribers { + pub(crate) fn subscribe(&mut self, owner: u64, sender: SyncSender>) -> Result<(), &'static str> { + if self.subscribers.len() >= MAX_SUBSCRIBERS { + return Err("Event subscriber limit reached"); + } + self.subscribers.insert(owner, Subscriber { sender, initial: true }); + Ok(()) + } + + pub(crate) fn remove(&mut self, owner: u64) { + self.subscribers.remove(&owner); + if self.subscribers.is_empty() { + self.previous = None; + } + } + + pub(crate) fn active(&self) -> bool { + !self.subscribers.is_empty() + } + + /// Called only after logical mutations for a dispatch have completed. + pub(crate) fn publish(&mut self, snapshot: Snapshot) { + let changes = self + .previous + .as_ref() + .map(|previous| snapshot.changes_since(previous)) + .unwrap_or_default(); + if changes.is_empty() && !self.subscribers.values().any(|subscriber| subscriber.initial) { + return; + } + self.generation = self.generation.wrapping_add(1); + let mut events = changes + .into_iter() + .map(|change| { + Arc::new(Event { + version: VERSION, + generation: self.generation, + last: false, + change, + }) + }) + .collect::>(); + if let Some(last) = events.last_mut() { + Arc::get_mut(last).unwrap().last = true; + } + let initial = self.subscribers.values().any(|subscriber| subscriber.initial).then(|| { + Arc::new(Event { + version: VERSION, + generation: self.generation, + last: true, + change: Change::Snapshot { + desktop: snapshot.clone(), + }, + }) + }); + self.subscribers.retain(|_, subscriber| { + let delivered = if subscriber.initial { + subscriber.sender.try_send(initial.as_ref().unwrap().clone()).is_ok() + } else { + events + .iter() + .all(|event| subscriber.sender.try_send(event.clone()).is_ok()) + }; + subscriber.initial = false; + // A partial generation is never followed by another one: an + // overflowing stream ends, and reconnecting starts with a snapshot. + delivered + }); + self.previous = self.active().then_some(snapshot); + } +} + +impl crate::Ferese { + pub(crate) fn publish_ipc_events(&mut self) { + if !self.ipc_events.active() + || self.desktop_transition.is_some() + || self + .direct_backend + .as_ref() + .is_some_and(|backend| backend.reconciling()) + { + return; + } + let snapshot = self.ipc_event_snapshot(); + self.ipc_events.publish(snapshot); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ferese_ipc::events::{ConfigState, LockState}; + use serde_json::json; + use std::sync::mpsc::{TryRecvError, sync_channel}; + + fn snapshot() -> Snapshot { + Snapshot { + outputs: json!([]), + workspaces: json!([]), + windows: json!([]), + focus: json!(null), + config: ConfigState::default(), + theme: json!({}), + lock: LockState { + phase: "unlocked".into(), + sleeping: false, + }, + } + } + + #[test] + fn initial_snapshot_and_changes_have_complete_generations_without_duplicates() { + let mut hub = Subscribers::default(); + let (sender, receive) = sync_channel(QUEUE_CAPACITY); + hub.subscribe(1, sender).unwrap(); + let mut state = snapshot(); + hub.publish(state.clone()); + assert!(matches!(receive.try_recv().unwrap().change, Change::Snapshot { .. })); + hub.publish(state.clone()); + assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty))); + state.outputs = json!([{"id": 2}]); + state.workspaces = json!([{"output": 2}]); + state.focus = json!({"output": 2}); + hub.publish(state); + let events = receive.try_iter().collect::>(); + assert_eq!(events.len(), 3); + assert!(events.iter().all(|event| event.generation == events[0].generation)); + assert!(!events[0].last && !events[1].last && events[2].last); + } + + #[test] + fn full_and_disconnected_subscribers_do_not_block_healthy_readers() { + let mut hub = Subscribers::default(); + let (slow, slow_receive) = sync_channel(1); + let (fast, fast_receive) = sync_channel(QUEUE_CAPACITY); + let (closed, closed_receive) = sync_channel(1); + drop(closed_receive); + hub.subscribe(1, slow).unwrap(); + hub.subscribe(2, fast).unwrap(); + hub.subscribe(3, closed).unwrap(); + let mut state = snapshot(); + hub.publish(state.clone()); + fast_receive.try_recv().unwrap(); + state.focus = json!({"window": 7}); + hub.publish(state); + assert!(matches!( + fast_receive.try_recv().unwrap().change, + Change::FocusChanged { .. } + )); + assert_eq!(hub.subscribers.len(), 1); + slow_receive.try_recv().unwrap(); + assert!(matches!(slow_receive.try_recv(), Err(TryRecvError::Disconnected))); + hub.remove(2); + assert!(!hub.active()); + assert!(hub.previous.is_none()); + } + + #[test] + fn subscribers_are_bounded_independently_of_command_workers() { + let mut hub = Subscribers::default(); + let mut receivers = Vec::new(); + for owner in 0..MAX_SUBSCRIBERS as u64 { + let (sender, receiver) = sync_channel(QUEUE_CAPACITY); + hub.subscribe(owner, sender).unwrap(); + receivers.push(receiver); + } + assert!(hub.subscribe(99, sync_channel(1).0).is_err()); + hub.remove(0); + assert!(hub.subscribe(99, sync_channel(1).0).is_ok()); + } + + #[test] + fn all_changed_domains_share_one_generation() { + let mut hub = Subscribers::default(); + let (sender, receive) = sync_channel(QUEUE_CAPACITY); + hub.subscribe(1, sender).unwrap(); + let mut state = snapshot(); + hub.publish(state.clone()); + receive.try_recv().unwrap(); + state.outputs = json!([{"id": 1}]); + state.workspaces = json!([{"output": 1}]); + state.windows = json!([{"id": 7, "title": "Terminal"}]); + state.focus = json!({"window": 7}); + state.config.revision = 1; + state.theme = json!({"mode": "light"}); + state.lock.phase = "locked".into(); + hub.publish(state); + let events = receive.try_iter().collect::>(); + assert_eq!(events.len(), 7); + assert!(events.iter().all(|event| event.generation == events[0].generation)); + assert!(events[..6].iter().all(|event| !event.last)); + assert!(events[6].last); + } + + #[test] + fn topology_events_wait_for_publication_and_report_config_commits() { + use crate::state::DesktopOutput; + use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel}; + use smithay::reexports::{calloop::EventLoop, wayland_server::Display}; + use smithay::utils::Transform; + + if !crate::startup_tests::private_runtime( + "ipc_events::tests::topology_events_wait_for_publication_and_report_config_commits", + ) { + return; + } + let mut event_loop = EventLoop::try_new().unwrap(); + let mut config = crate::config::Config::default().runtime_config().unwrap(); + config.wallpaper.path = None; + let mut state = crate::Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap(); + let output = |name: &str, x| DesktopOutput { + output: Output::new( + name.into(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ), + identity: name.into(), + mode: Mode { + size: (800, 600).into(), + refresh: 60_000, + }, + transform: Transform::Normal, + scale: Scale::Fractional(1.0), + position: (x, 0).into(), + }; + let (sender, receive) = sync_channel(QUEUE_CAPACITY); + state.ipc_events.subscribe(900, sender).unwrap(); + state.begin_desktop_transition(); + let changes = state.publish_desktop(vec![output("a", 0)]).unwrap(); + state.publish_ipc_events(); + assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty))); + state.finish_desktop_transition(changes); + state.publish_ipc_events(); + let initial = receive.try_recv().unwrap(); + let Change::Snapshot { desktop } = &initial.change else { + panic!("initial snapshot") + }; + assert_eq!(desktop.outputs.as_array().unwrap().len(), 1); + assert_eq!(desktop.workspaces[0]["output"], desktop.outputs[0]["id"]); + state.begin_desktop_transition(); + let a = state.current_desktop_outputs().remove(0); + let changes = state.publish_desktop(vec![a, output("b", 800)]).unwrap(); + state.publish_ipc_events(); + assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty))); + state.finish_desktop_transition(changes); + state.publish_ipc_events(); + let events = receive.try_iter().collect::>(); + assert!(events.last().unwrap().last); + assert!(events.iter().all(|event| event.generation == events[0].generation)); + let final_state = state.ipc_event_snapshot(); + assert_eq!(final_state.outputs.as_array().unwrap().len(), 2); + for workspace in final_state.workspaces.as_array().unwrap() { + assert!( + final_state + .outputs + .as_array() + .unwrap() + .iter() + .any(|output| output["id"] == workspace["output"]) + ); + } + let revision = state.ipc_events.config_revision; + state + .reload_config_source("appearance { focus-effect { inactive-opacity 0.8; }; }".into()) + .unwrap(); + assert_eq!(state.ipc_events.config_revision, revision + 1); + state.publish_ipc_events(); + assert!( + receive + .try_iter() + .any(|event| matches!(event.change, Change::ConfigChanged { .. })) + ); + + // Negotiate with the real listener and connection worker, then verify + // that the initial snapshot reaches the wire after dispatch publication. + for version in [VERSION + 1, VERSION] { + let socket = ferese_ipc::socket::resolve(None).unwrap(); + let (completed, completion) = sync_channel(1); + let worker = std::thread::spawn(move || { + let mut stream = std::os::unix::net::UnixStream::connect(socket).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + ferese_ipc::write_frame( + &mut stream, + &ferese_ipc::Request { + version: ferese_ipc::VERSION, + id: 42, + kind: "command".into(), + command: "event-stream".into(), + args: json!({"version": version}), + }, + ) + .unwrap(); + let response: ferese_ipc::Response = ferese_ipc::read_frame(&mut stream).unwrap(); + assert_eq!(response.id, 42); + if version != VERSION { + assert_eq!(response.error.unwrap().code, "unsupported_event_version"); + } else { + assert!(response.error.is_none()); + let initial: Event = ferese_ipc::read_frame(&mut stream).unwrap(); + assert!(initial.last); + let Change::Snapshot { desktop } = initial.change else { + panic!("initial wire snapshot") + }; + assert_eq!(desktop.outputs.as_array().unwrap().len(), 2); + } + completed.send(()).unwrap(); + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while completion.try_recv().is_err() { + assert!(std::time::Instant::now() < deadline, "subscription worker timed out"); + event_loop + .dispatch(std::time::Duration::from_millis(10), &mut state) + .unwrap(); + state.publish_ipc_events(); + } + worker.join().unwrap(); + } + } +} diff --git a/compositor/src/main.rs b/compositor/src/main.rs index efb0a82..2cba7c1 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -15,6 +15,7 @@ mod idle_inhibition; mod input; mod input_capture; mod ipc; +mod ipc_events; mod metrics; mod monitor_identity; mod output_policy; @@ -208,6 +209,8 @@ fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Fe warn!(%error, "could not schedule wallpaper upload retry"); } + state.publish_ipc_events(); + // Clients need registry/sync/configure replies even when no frame is drawn. if let Err(error) = state.display_handle.flush_clients() { warn!(%error, "failed to flush Wayland clients"); diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index 231485f..3cdd263 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -347,6 +347,7 @@ impl crate::Ferese { false } }); + self.ipc_events.config_revision = self.ipc_events.config_revision.wrapping_add(1); tracing::info!("configuration reloaded live"); Ok(()) } diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 15d3d30..c93a2eb 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -115,6 +115,19 @@ pub(crate) struct Lock { } impl Lock { + pub(crate) fn event_state(&self) -> ferese_ipc::events::LockState { + let phase = match self.lifecycle { + Lifecycle::Unlocked => "unlocked", + Lifecycle::Acquiring(_) => "acquiring", + Lifecycle::Locked(_) => "locked", + Lifecycle::Orphaned => "orphaned", + }; + ferese_ipc::events::LockState { + phase: phase.into(), + sleeping: self.sleeping, + } + } + pub(crate) fn active(&self) -> bool { !matches!(self.lifecycle, Lifecycle::Unlocked) } diff --git a/compositor/src/session_lock/tests.rs b/compositor/src/session_lock/tests.rs index e4c799d..e5172f5 100644 --- a/compositor/src/session_lock/tests.rs +++ b/compositor/src/session_lock/tests.rs @@ -689,3 +689,39 @@ fn lock_input_preserves_owner_click_grabs_and_rejects_retired_surfaces() { assert_eq!(pointer.current_focus(), None); assert_eq!(fixture.state.seat.get_keyboard().unwrap().current_focus(), None); } + +#[test] +fn ipc_events_follow_lock_lifecycle_without_exposing_desktop_objects() { + let Some(mut fixture) = + Fixture::new("session_lock::tests::ipc_events_follow_lock_lifecycle_without_exposing_desktop_objects") + else { + return; + }; + let output = fixture.output("lock-events"); + let (sender, receive) = std::sync::mpsc::sync_channel(crate::ipc_events::QUEUE_CAPACITY); + fixture.state.ipc_events.subscribe(900, sender).unwrap(); + fixture.state.publish_ipc_events(); + receive.try_recv().unwrap(); + let mut owner = fixture.acquire(); + fixture.state.publish_ipc_events(); + let snapshot = fixture.state.ipc_event_snapshot(); + assert!(snapshot.workspaces.as_array().unwrap().is_empty()); + assert!(snapshot.windows.as_array().unwrap().is_empty()); + assert!(snapshot.focus.is_null()); + assert_eq!(snapshot.lock.phase, "acquiring"); + assert!(receive.try_iter().any( + |event| matches!(&event.change, ferese_ipc::events::Change::LockChanged { lock } if lock.phase == "acquiring") + )); + owner.surface(&mut fixture, &output); + fixture.state.session_lock.presented.insert(output); + fixture.state.confirm_lock_if_ready(); + fixture.state.publish_ipc_events(); + assert!(receive.try_iter().any( + |event| matches!(&event.change, ferese_ipc::events::Change::LockChanged { lock } if lock.phase == "locked") + )); + fixture.state.unlock(); + fixture.state.publish_ipc_events(); + assert!(receive.try_iter().any( + |event| matches!(&event.change, ferese_ipc::events::Change::LockChanged { lock } if lock.phase == "unlocked") + )); +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 999ae38..1f97f5d 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -398,6 +398,7 @@ pub struct Ferese { pub(crate) lock_idle: IdleSettings, pub(crate) config_source: Option, pub(crate) config_sections: Option, + pub(crate) ipc_events: crate::ipc_events::Subscribers, pub(crate) config_worker: Option, pub(crate) theme_engine: crate::theme::Engine, pub start_time: Instant, @@ -646,6 +647,7 @@ impl Ferese { let mut state = Self { session_lock_state, session_lock: crate::session_lock::Lock::default(), + ipc_events: crate::ipc_events::Subscribers::default(), lock_idle: config.lock_idle, config_source: None, config_sections: None, diff --git a/crates/ferese-ipc/src/events.rs b/crates/ferese-ipc/src/events.rs new file mode 100644 index 0000000..12c5aaf --- /dev/null +++ b/crates/ferese-ipc/src/events.rs @@ -0,0 +1,134 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Event schema version, independent of command framing versions. +pub const VERSION: u32 = 1; + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +pub struct ConfigState { + pub revision: u64, + pub error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct LockState { + pub phase: String, + pub sleeping: bool, +} + +/// A coherent logical desktop view. Desktop objects are redacted while locked. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Snapshot { + pub outputs: Value, + pub workspaces: Value, + pub windows: Value, + pub focus: Value, + pub config: ConfigState, + pub theme: Value, + pub lock: LockState, +} + +/// Replacement values for each changed domain; not animation frames or client damage. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Change { + Snapshot { desktop: Snapshot }, + OutputsChanged { outputs: Value }, + WorkspacesChanged { workspaces: Value }, + WindowsChanged { windows: Value }, + FocusChanged { focus: Value }, + ConfigChanged { config: ConfigState }, + ThemeChanged { theme: Value }, + LockChanged { lock: LockState }, +} + +/// All changes from one dispatch share a generation. The last marks its end. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Event { + pub version: u32, + pub generation: u64, + pub last: bool, + #[serde(flatten)] + pub change: Change, +} + +impl Event { + pub fn validate(&self) -> Result<(), String> { + if self.version == VERSION { + Ok(()) + } else { + Err(format!("Unsupported IPC event version {}", self.version)) + } + } +} + +impl Snapshot { + pub fn changes_since(&self, previous: &Self) -> Vec { + let mut changes = Vec::new(); + if self.outputs != previous.outputs { + changes.push(Change::OutputsChanged { + outputs: self.outputs.clone(), + }); + } + if self.workspaces != previous.workspaces { + changes.push(Change::WorkspacesChanged { + workspaces: self.workspaces.clone(), + }); + } + if self.windows != previous.windows { + changes.push(Change::WindowsChanged { + windows: self.windows.clone(), + }); + } + if self.focus != previous.focus { + changes.push(Change::FocusChanged { + focus: self.focus.clone(), + }); + } + if self.config != previous.config { + changes.push(Change::ConfigChanged { + config: self.config.clone(), + }); + } + if self.theme != previous.theme { + changes.push(Change::ThemeChanged { + theme: self.theme.clone(), + }); + } + if self.lock != previous.lock { + changes.push(Change::LockChanged { + lock: self.lock.clone(), + }); + } + changes + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn events_round_trip_with_an_independent_schema_version() { + let event = Event { + version: VERSION, + generation: 42, + last: true, + change: Change::FocusChanged { + focus: serde_json::json!({"window": 7}), + }, + }; + let mut bytes = Vec::new(); + crate::write_frame(&mut bytes, &event).unwrap(); + let decoded: Event = crate::read_frame(&mut bytes.as_slice()).unwrap(); + assert_eq!(decoded, event); + decoded.validate().unwrap(); + assert!( + Event { + version: VERSION + 1, + ..event + } + .validate() + .is_err() + ); + } +} diff --git a/crates/ferese-ipc/src/lib.rs b/crates/ferese-ipc/src/lib.rs index 0fa040e..c12b0a5 100644 --- a/crates/ferese-ipc/src/lib.rs +++ b/crates/ferese-ipc/src/lib.rs @@ -5,7 +5,9 @@ use std::{fmt, io}; use serde::{Deserialize, Serialize}; use serde_json::Value; +pub mod events; pub mod media; +pub mod socket; pub mod theme; pub mod xwayland; diff --git a/crates/ferese-ipc/src/socket.rs b/crates/ferese-ipc/src/socket.rs new file mode 100644 index 0000000..696cd34 --- /dev/null +++ b/crates/ferese-ipc/src/socket.rs @@ -0,0 +1,49 @@ +use std::ffi::OsStr; +use std::io; +use std::path::{Path, PathBuf}; + +/// Resolve an IPC target: explicit path, FERESE_SOCKET, then the runtime default. +pub fn resolve(explicit: Option<&Path>) -> io::Result { + resolve_with( + explicit, + std::env::var_os("FERESE_SOCKET").as_deref(), + std::env::var_os("XDG_RUNTIME_DIR").as_deref(), + ) +} + +fn resolve_with(explicit: Option<&Path>, socket: Option<&OsStr>, runtime: Option<&OsStr>) -> io::Result { + if let Some(path) = explicit { + if path.as_os_str().is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "IPC socket path is empty")); + } + return Ok(path.to_owned()); + } + if let Some(socket) = socket.filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(socket)); + } + runtime + .filter(|value| !value.is_empty()) + .map(|directory| PathBuf::from(directory).join("ferese/control.sock")) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_nested_target_wins_over_the_host_environment() { + let runtime = Some(OsStr::new("/run/user/1000")); + let host = OsStr::new("/run/user/1000/ferese/control.sock"); + let nested = Path::new("/tmp/nested/ferese/control.sock"); + assert_eq!(resolve_with(Some(nested), Some(host), runtime).unwrap(), nested); + assert_eq!(resolve_with(None, Some(nested.as_os_str()), runtime).unwrap(), nested); + assert_eq!(resolve_with(None, None, runtime).unwrap(), Path::new(host)); + assert_eq!( + resolve_with(None, Some(OsStr::new("")), runtime).unwrap(), + Path::new(host) + ); + assert!(resolve_with(None, None, None).is_err()); + assert!(resolve_with(Some(Path::new("")), Some(host), runtime).is_err()); + } +} diff --git a/crates/ferese-ipc/src/theme.rs b/crates/ferese-ipc/src/theme.rs index 0b2ffbc..c8444da 100644 --- a/crates/ferese-ipc/src/theme.rs +++ b/crates/ferese-ipc/src/theme.rs @@ -46,10 +46,7 @@ impl Snapshot { } pub fn socket_path() -> io::Result { - std::env::var_os("XDG_RUNTIME_DIR") - .filter(|value| !value.is_empty()) - .map(|directory| PathBuf::from(directory).join("ferese/control.sock")) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set")) + crate::socket::resolve(None) } pub struct Connection { @@ -59,7 +56,11 @@ pub struct Connection { impl Connection { pub fn connect() -> io::Result { - let stream = UnixStream::connect(socket_path()?)?; + Self::connect_to(socket_path()?) + } + + pub fn connect_to(path: impl AsRef) -> io::Result { + let stream = UnixStream::connect(path)?; stream.set_write_timeout(Some(Duration::from_secs(2)))?; stream.set_read_timeout(Some(Duration::from_secs(2)))?; Ok(Self { stream, id: 0 }) diff --git a/docs/README.md b/docs/README.md index e541cbf..bd280f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - [Lock screen](locking.md): change its appearance, set up automatic locking and configure display sleep. - [Screen sharing and recording](screen-sharing.md): choose a source and stop sharing. - [Desktop portals](portals.md): services available to applications. +- [feresectl and IPC events](ipc.md): query the desktop, target a session and follow changes. ## Develop Ferese diff --git a/docs/configuration.md b/docs/configuration.md index a45795c..761b71f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -936,7 +936,7 @@ Wayland or portal protocols; a fullscreen window alone does not prove playback. These settings control automatic inhibition. They do not override an application's own Wayland or portal request, and they do not block manual -locking or suspend. Run `feresectl get-idle-inhibition` to inspect the current +locking or suspend. Run `feresectl idle-inhibition` to inspect the current inhibition state and discovered players. Changes take effect on config reload. ## Optional session protocols diff --git a/docs/ipc.md b/docs/ipc.md new file mode 100644 index 0000000..045c55a --- /dev/null +++ b/docs/ipc.md @@ -0,0 +1,125 @@ +# feresectl and IPC events + +`feresectl` controls a running Ferese session. Run `feresectl --help` for commands +and `feresectl COMMAND --help` for arguments. + +## Queries + +Queries print plain text by default. Add `-j` or `--json` when passing results to +another program. The flag works before or after a subcommand. + +```sh +feresectl outputs +feresectl windows -j +feresectl -j workspaces +feresectl focused-window +feresectl idle-inhibition +feresectl keybindings +feresectl session-state +feresectl output-profiles +feresectl theme get +feresectl xwayland status +feresectl media +``` + +The `get-*` spellings remain hidden aliases for the corresponding queries and +print a deprecation warning on stderr. They use the same output rules: scripts +that previously parsed their JSON must add `-j`. New scripts should use the +canonical names. The aliases will be removed in a later release. + +Screenshots still write PNG bytes to stdout, including when `-j` is present: + +```sh +feresectl screenshot > desktop.png +feresectl screenshot -g '0,0 800x600' > region.png +feresectl screenshot-window 7 > window.png +``` + +## Choose a session + +Every feresectl IPC command, including theme commands and subscriptions, resolves +its target in this order: + +1. `--socket PATH` +2. A nonempty `FERESE_SOCKET` +3. `$XDG_RUNTIME_DIR/ferese/control.sock` + +```sh +feresectl --socket /tmp/ferese-preview/ferese/control.sock outputs +FERESE_SOCKET=/tmp/ferese-preview/ferese/control.sock feresectl theme get -j +``` + +`--socket` selects an existing server; it does not create one or change where the +compositor listens. A nested compositor uses its own runtime directory. +`autostart` launches desktop entries locally and does not use IPC. + +## Follow changes + +```sh +feresectl event-stream +feresectl --socket /tmp/ferese-preview/ferese/control.sock event-stream +``` + +`event-stream` writes one JSON value per line and flushes each line. It always +uses JSON, so `-j` is optional. `theme subscribe` also keeps its newline-delimited +JSON output. + +The first event is a `snapshot` containing outputs, workspaces, windows, focus, +config status, the logical theme, and lock state. Later events contain replacement +values for changed domains: + +| Type | Payload field | +| --- | --- | +| `outputs_changed` | `outputs` | +| `workspaces_changed` | `workspaces` | +| `windows_changed` | `windows` | +| `focus_changed` | `focus` | +| `config_changed` | `config` | +| `theme_changed` | `theme` | +| `lock_changed` | `lock` | + +For example: + +```json +{"version":1,"generation":12,"last":true,"type":"focus_changed","focus":{"window":7,"output":1,"workspace":3}} +``` + +Event schema version 1 is defined in `ferese-ipc::events`, independently of the +command protocol version. A subscriber requests that version using the framed +`event-stream` command. The server sends a normal response acknowledging the +subscription, followed by framed events on the same connection. That connection +is then reserved for events. + +Changes are sampled after an event-loop dispatch. Output reconciliation must +finish before a desktop generation is published. Events from the same dispatch +share a `generation`; only its final event has `last: true`. Consumers maintaining +a desktop model should apply the whole generation together. Intermediate changes +within a dispatch are coalesced, so this stream is not an audit log of every input +or mutation. Generation numbers may have gaps. + +Window events describe identity, title, activation, workspace, floating state, +and fullscreen state. They omit visual geometry, animation frames, and client +damage. Theme events omit the presented crossfade state and its frame revisions. +Config revisions advance when a changed configuration is accepted; rejected +reloads leave that revision intact and report an error. Lock events distinguish +`unlocked`, `acquiring`, `locked`, and `orphaned`, and include display sleep state. +While locked, snapshots and replacement events redact windows, workspaces, +focus, and output workspace/focus fields. Unlocking restores those domains. + +## Delivery limits + +The compositor allows at most 16 event subscribers within its existing limit of +64 IPC connection workers. Each subscriber has a queue of at most 32 events. +Publishing uses non-blocking sends. A full or disconnected queue removes that +subscriber; socket writes happen on its worker with a two-second write timeout. +Idle disconnected streams are checked every 250 milliseconds. + +Events are not replayed. An overflow can end a stream partway through a generation. +Discard any generation without `last: true`, reconnect, and replace local state +with the new initial snapshot. Socket authentication and the existing one-MiB +frame limit also apply to event streams. + +When there are no subscribers, event publication does not build desktop +snapshots. With subscribers, it compares logical domain snapshots after each +dispatch. Ordinary damage, cursor movement, and redraw code do not publish events +directly. diff --git a/docs/native-soak-testing.md b/docs/native-soak-testing.md index fa38b65..1fbf62f 100644 --- a/docs/native-soak-testing.md +++ b/docs/native-soak-testing.md @@ -37,7 +37,7 @@ The local probe can also be run independently against a disposable Ferese sessio ```bash WAYLAND_DISPLAY=wayland-1 /path/to/ferese-malformed-client -target/debug/feresectl get-outputs +target/debug/feresectl outputs ``` Do not point the malformed-client probe at the host compositor socket. diff --git a/feresectl/Cargo.toml b/feresectl/Cargo.toml index a5666eb..07e2f02 100644 --- a/feresectl/Cargo.toml +++ b/feresectl/Cargo.toml @@ -6,7 +6,12 @@ license.workspace = true rust-version.workspace = true [dependencies] +clap = { version = "4.5", features = ["derive"] } ferese-config = { path = "../crates/ferese-config" } ferese-ipc = { path = "../crates/ferese-ipc" } +serde.workspace = true serde_json.workspace = true libc.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/feresectl/src/cli.rs b/feresectl/src/cli.rs new file mode 100644 index 0000000..8a46894 --- /dev/null +++ b/feresectl/src/cli.rs @@ -0,0 +1,285 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; +use serde_json::{Value, json}; + +/// Control a running Ferese session. +#[derive(Debug, Parser)] +#[command(version)] +pub struct Cli { + /// Print query results as JSON. + #[arg(short = 'j', long, global = true)] + pub json: bool, + /// Connect to this IPC socket instead of FERESE_SOCKET or the runtime default. + #[arg(long, global = true)] + pub socket: Option, + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Show the active window. + #[command(alias = "get-focused-window")] + FocusedWindow, + /// List windows. + #[command(alias = "get-windows")] + Windows, + /// Show idle inhibitors. + #[command(alias = "get-idle-inhibition")] + IdleInhibition, + /// List workspaces and their output ownership. + #[command(alias = "get-workspaces")] + Workspaces, + /// List connected outputs and their applied configuration. + #[command(alias = "get-outputs")] + Outputs, + /// List configured keybindings. + #[command(alias = "get-keybindings")] + Keybindings, + /// Show session and inhibition state. + #[command(alias = "get-session-state")] + SessionState, + /// Toggle floating for the focused window. + ToggleFloating, + /// Toggle fullscreen for the focused window. + ToggleFullscreen, + /// Toggle maximization for the focused window. + ToggleMaximized, + /// Switch between scrolling and tree layout. + ToggleLayout, + /// Show or hide the workspace overview. + ToggleOverview, + /// Show or hide the keybinding guide. + ToggleKeybindingGuide, + /// Cycle the focused column width. + CycleColumnWidth, + /// Center the focused column. + CenterColumn, + /// Pull a window into the focused column. + Consume, + /// Move a window out of the focused column. + Expel, + /// Close the focused window. + Close, + /// Cycle the display layout. + ToggleDisplayMode, + /// List available output profiles. + OutputProfiles, + /// Confirm the pending output configuration. + OutputConfirm, + /// Revert the pending output configuration. + OutputRevert, + /// Reload the compositor configuration. + ReloadConfig, + /// Exit the compositor. + Exit, + /// Open the logout confirmation. + RequestLogout, + /// Return to the previous workspace. + WorkspaceBackAndForth, + /// Focus the previously focused window. + FocusLastWindow, + /// Focus the next recently used window. + FocusMruNext, + /// Focus the previous recently used window. + FocusMruPrevious, + /// Focus a window in a direction. + Focus { direction: Direction }, + /// Move the focused window in a direction. + Move { direction: Direction }, + /// Resize the focused window in a direction. + Resize { direction: Direction }, + /// Activate a workspace by its one-based index. + Workspace { + #[arg(value_parser = clap::value_parser!(u32).range(1..))] + index: u32, + }, + /// Move the focused window to a workspace. + MoveToWorkspace { + #[arg(value_parser = clap::value_parser!(u32).range(1..))] + index: u32, + }, + /// Apply a display layout. + OutputLayout { layout: DisplayLayout }, + /// Select an output profile, or auto. + OutputProfile { name: String }, + /// Enable or disable the internal display. + OutputInternal { state: Switch }, + /// Write a screenshot PNG to stdout. + Screenshot { + #[arg(short = 'g', long)] + geometry: Option, + }, + /// Write a window screenshot PNG to stdout. + ScreenshotWindow { window: u64 }, + /// Launch desktop autostart entries. + Autostart, + /// Inspect or change the theme. + Theme { + #[command(subcommand)] + action: ThemeAction, + }, + /// Inspect or retry the XWayland server. + Xwayland { + #[command(subcommand)] + action: XwaylandAction, + }, + /// Inspect media players or control playback. + Media { + #[command(subcommand)] + action: Option, + }, + /// Follow compositor changes as newline-delimited JSON. + EventStream, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum Direction { + Left, + Right, + Up, + Down, +} +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum DisplayLayout { + InternalOnly, + ExternalOnly, + Extend, + Mirror, +} +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum Switch { + On, + Off, +} +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum ThemeMode { + Light, + Dark, + Auto, +} + +#[derive(Debug, Subcommand)] +pub enum ThemeAction { + /// Show the current theme. + #[command(alias = "status")] + Get, + /// Follow theme snapshots as newline-delimited JSON. + Subscribe, + /// Set the appearance mode. + Mode { mode: ThemeMode }, + /// Resolve a configuration file without applying it. + Preview { path: PathBuf }, +} +#[derive(Debug, Subcommand)] +pub enum XwaylandAction { + /// Show XWayland status. + Status, + /// Retry starting XWayland after a failure. + Retry, +} +#[derive(Debug, Subcommand)] +pub enum MediaAction { + /// Show players and the active player. + Get, + /// Toggle playback. + PlayPause, + /// Skip to the next track. + Next, + /// Return to the previous track. + Previous, + /// Raise the active player's window. + Raise, + /// Select the active player automatically. + Auto, + /// Prefer a particular player. + Pin { player: String }, + /// Exclude a player from automatic selection. + Ignore { player: String }, + /// Include a previously ignored player. + Unignore { player: String }, +} + +fn value_name(value: &impl ValueEnum) -> String { + value + .to_possible_value() + .expect("visible enum value") + .get_name() + .to_owned() +} + +impl Command { + pub fn ipc(&self) -> Option<(&'static str, Value)> { + let (command, args) = match self { + Self::FocusedWindow => ("get-focused-window", json!({})), + Self::Windows => ("get-windows", json!({})), + Self::IdleInhibition => ("get-idle-inhibition", json!({})), + Self::Workspaces => ("get-workspaces", json!({})), + Self::Outputs => ("outputs", json!({})), + Self::Keybindings => ("get-keybindings", json!({})), + Self::SessionState => ("get-session-state", json!({})), + Self::ToggleFloating => ("toggle-floating", json!({})), + Self::ToggleFullscreen => ("toggle-fullscreen", json!({})), + Self::ToggleMaximized => ("toggle-maximized", json!({})), + Self::ToggleLayout => ("toggle-layout", json!({})), + Self::ToggleOverview => ("toggle-overview", json!({})), + Self::ToggleKeybindingGuide => ("toggle-keybinding-guide", json!({})), + Self::CycleColumnWidth => ("cycle-column-width", json!({})), + Self::CenterColumn => ("center-column", json!({})), + Self::Consume => ("consume", json!({})), + Self::Expel => ("expel", json!({})), + Self::Close => ("close", json!({})), + Self::ToggleDisplayMode => ("toggle-display-mode", json!({})), + Self::OutputProfiles => ("output-profiles", json!({})), + Self::OutputConfirm => ("output-confirm", json!({})), + Self::OutputRevert => ("output-revert", json!({})), + Self::ReloadConfig => ("reload-config", json!({})), + Self::Exit => ("exit", json!({})), + Self::RequestLogout => ("request-logout", json!({})), + Self::WorkspaceBackAndForth => ("workspace-back-and-forth", json!({})), + Self::FocusLastWindow => ("focus-last-window", json!({})), + Self::FocusMruNext => ("focus-mru-next", json!({})), + Self::FocusMruPrevious => ("focus-mru-previous", json!({})), + Self::Focus { direction } => ("focus", json!({"direction": value_name(direction)})), + Self::Move { direction } => ("move", json!({"direction": value_name(direction)})), + Self::Resize { direction } => ("resize", json!({"direction": value_name(direction)})), + Self::Workspace { index } => ("workspace", json!({"index": index})), + Self::MoveToWorkspace { index } => ("move-to-workspace", json!({"index": index})), + Self::OutputLayout { layout } => ("output-layout", json!({"layout": value_name(layout)})), + Self::OutputProfile { name } => ("output-profile", json!({"name": name})), + Self::OutputInternal { state } => ("output-internal", json!({"enabled": matches!(state, Switch::On)})), + Self::Screenshot { + geometry: Some(geometry), + } => ("screenshot", json!({"geometry": geometry})), + Self::Screenshot { geometry: None } => ("screenshot", json!({})), + Self::ScreenshotWindow { window } => ("screenshot-window", json!({"window": window})), + Self::Xwayland { action } => ( + match action { + XwaylandAction::Status => ferese_ipc::xwayland::STATUS_COMMAND, + XwaylandAction::Retry => ferese_ipc::xwayland::RETRY_COMMAND, + }, + json!({}), + ), + Self::Media { + action: None | Some(MediaAction::Get), + } => ("media-get", json!({})), + Self::Media { action: Some(action) } => { + let args = match action { + MediaAction::Pin { player } => json!({"action": "pin", "player": player}), + MediaAction::Ignore { player } => json!({"action": "ignore", "player": player, "ignored": true}), + MediaAction::Unignore { player } => json!({"action": "ignore", "player": player, "ignored": false}), + MediaAction::PlayPause => json!({"action": "play-pause"}), + MediaAction::Next => json!({"action": "next"}), + MediaAction::Previous => json!({"action": "previous"}), + MediaAction::Raise => json!({"action": "raise"}), + MediaAction::Auto => json!({"action": "auto"}), + MediaAction::Get => unreachable!(), + }; + ("media-action", args) + } + Self::Autostart | Self::Theme { .. } | Self::EventStream => return None, + }; + Some((command, args)) + } +} diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 73db727..4b961bc 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -1,127 +1,130 @@ mod autostart; +mod cli; +mod output; use std::error::Error; use std::io::Write; use std::os::unix::net::UnixStream; -use std::path::PathBuf; +use std::path::Path; use std::{env, fs, io}; +use clap::{Parser, ValueEnum}; +use cli::{Cli, Command, ThemeAction}; use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; use serde_json::{Value, json}; fn main() -> Result<(), Box> { - if env::args().nth(1).as_deref() == Some("autostart") { - if env::args().len() != 2 { - return Err("usage: feresectl autostart".into()); - } + let cli = Cli::parse(); + if matches!(cli.command, Command::Autostart) { return autostart::run(); } - if env::args().nth(1).as_deref() == Some("theme") { - return theme_command(env::args().skip(2).collect()); - } - if env::args().nth(1).as_deref() == Some("xwayland") { - return xwayland_command(env::args().skip(2).collect()); - } - let (command, args) = parse_args(env::args().skip(1))?; - let request = Request { - version: VERSION, - id: 1, - kind: "command".to_owned(), - command: command.clone(), - 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()); - } - - if matches!(command.as_str(), "screenshot" | "screenshot-window") { - write_png(&response) - } else { - println!( - "{}", - serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))? + for alias in env::args().skip(1).filter(|arg| { + matches!( + arg.as_str(), + "get-outputs" + | "get-windows" + | "get-workspaces" + | "get-focused-window" + | "get-idle-inhibition" + | "get-session-state" + | "get-keybindings" + ) + }) { + eprintln!( + "feresectl: {alias} is deprecated; use {}", + alias.trim_start_matches("get-") ); - Ok(()) + } + let socket = ferese_ipc::socket::resolve(cli.socket.as_deref())?; + match &cli.command { + Command::Theme { action } => theme_command(action, &socket, cli.json), + Command::EventStream => event_stream(&socket), + command => { + let (command, args) = command.ipc().expect("IPC command"); + let mut stream = UnixStream::connect(socket)?; + let result = request(&mut stream, command, args)?; + if matches!(command, "screenshot" | "screenshot-window") { + write_png(&Response::success(1, result)) + } else { + output::print(&result, cli.json) + } + } } } -fn theme_command(args: Vec) -> Result<(), Box> { - let mut connection = ferese_ipc::theme::Connection::connect()?; - match args.as_slice() { - [command] if command == "get" || command == "status" => { - println!( - "{}", - serde_json::to_string_pretty(&connection.get(ferese_config::families::builtins)?)? - ); - } - [command] if command == "subscribe" => { +fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result> { + write_frame( + stream, + &Request { + version: VERSION, + id: 1, + kind: "command".into(), + command: command.into(), + args, + }, + )?; + let response: Response = read_frame(stream)?; + if response.version != VERSION || response.id != 1 { + return Err("Unexpected IPC response".into()); + } + if let Some(error) = response.error { + return Err(format!("{}: {}", error.code, error.message).into()); + } + Ok(response.result.unwrap_or(Value::Null)) +} + +fn theme_command(action: &ThemeAction, socket: &Path, json_output: bool) -> Result<(), Box> { + let mut connection = ferese_ipc::theme::Connection::connect_to(socket)?; + let result = match action { + ThemeAction::Get => serde_json::to_value(connection.get(ferese_config::families::builtins)?)?, + ThemeAction::Subscribe => { let mut snapshot = connection.get(ferese_config::families::builtins)?; + let mut stdout = io::stdout().lock(); loop { - println!("{}", serde_json::to_string(&snapshot)?); + if !output::write_json_line(&mut stdout, &snapshot)? { + return Ok(()); + } snapshot = connection.watch(snapshot.revision, ferese_config::families::builtins)?; } } - [command, mode] if command == "mode" && matches!(mode.as_str(), "light" | "dark" | "auto") => { - println!( - "{}", - serde_json::to_string_pretty(&connection.call("theme-set-mode", json!({"mode": mode}))?)? - ); - } - [command, path] if command == "preview" => { - let source = fs::read_to_string(path)?; - println!( - "{}", - serde_json::to_string_pretty(&connection.call( - "theme-preview", - json!({"source": source, "directory": std::path::Path::new(path).canonicalize()?.parent()}) - )?)? - ); - } - _ => { - return Err( - "usage: feresectl theme ".into(), - ); - } - } - Ok(()) + ThemeAction::Mode { mode } => connection.call( + "theme-set-mode", + json!({"mode": mode.to_possible_value().unwrap().get_name()}), + )?, + ThemeAction::Preview { path } => connection.call( + "theme-preview", + json!({ + "source": fs::read_to_string(path)?, "directory": path.canonicalize()?.parent(), + }), + )?, + }; + output::print(&result, json_output) } -fn xwayland_command(args: Vec) -> Result<(), Box> { - use ferese_ipc::xwayland::{RETRY_COMMAND, STATUS_COMMAND, Status}; - - let command = match args.as_slice() { - [command] if command == "status" => STATUS_COMMAND, - [command] if command == "retry" => RETRY_COMMAND, - _ => return Err("usage: feresectl xwayland ".into()), - }; - - let request = Request { - version: VERSION, - id: 1, - kind: "command".to_owned(), - command: command.to_owned(), - args: json!({}), - }; - 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()); +fn event_stream(socket: &Path) -> Result<(), Box> { + let mut stream = UnixStream::connect(socket)?; + request( + &mut stream, + "event-stream", + json!({"version": ferese_ipc::events::VERSION}), + )?; + let mut stdout = io::stdout().lock(); + loop { + let event: ferese_ipc::events::Event = read_frame(&mut stream)?; + event.validate()?; + if !output::write_json_line(&mut stdout, &event)? { + return Ok(()); + } } +} - let status = Status::from_value(response.result.unwrap_or(Value::Null))?; - if command == RETRY_COMMAND { - println!( - "X11 retry requested; current state: {}", - serde_json::to_string(&status.state)? - ); - } - println!("{}", serde_json::to_string_pretty(&status)?); - Ok(()) +#[cfg(test)] +fn parse_args(args: impl IntoIterator) -> Result<(String, Value), String> { + let cli = Cli::try_parse_from(std::iter::once("feresectl".to_owned()).chain(args)).map_err(|e| e.to_string())?; + cli.command + .ipc() + .map(|(command, args)| (command.into(), args)) + .ok_or_else(|| "not an IPC command".into()) } // The compositor stages the PNG in a private directory and replies with its @@ -146,133 +149,6 @@ fn write_png(response: &Response) -> Result<(), Box> { 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() { - "output-layout" => { - exactly_one(&command, &positional, "internal-only, external-only, extend or mirror")?; - if !["internal-only", "external-only", "extend", "mirror"].contains(&positional[0].as_str()) { - return Err("unknown display layout".into()); - } - json!({ "layout": positional[0] }) - } - "output-profile" => { - exactly_one(&command, &positional, "profile name or auto")?; - json!({ "name": positional[0] }) - } - "output-internal" => { - exactly_one(&command, &positional, "on or off")?; - let enabled = match positional[0].as_str() { - "on" => true, - "off" => false, - _ => return Err("output-internal requires on or off".into()), - }; - json!({ "enabled": enabled }) - } - "screenshot-window" => { - exactly_one(&command, &positional, "window ID")?; - let window = positional[0] - .parse::() - .map_err(|_| "Expected an unsigned window ID")?; - json!({"window":window}) - } - "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 }) - } - "media" => { - return match positional.as_slice() { - [] => Ok(("media-get".into(), json!({}))), - [mode] if mode == "get" => Ok(("media-get".into(), json!({}))), - [action] if ["play-pause", "next", "previous", "raise", "auto"].contains(&action.as_str()) => { - Ok(("media-action".into(), json!({"action": action}))) - } - [action, player] if ["pin", "ignore", "unignore"].contains(&action.as_str()) => { - Ok(("media-action".into(), json!({"action":if action == "pin" { "pin" } else { "ignore" },"player":player,"ignored":action == "ignore"}))) - } - _ => Err("usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]".into()), - }; - } - "screenshot" => match positional.as_slice() { - [] => json!({}), - [flag, geometry] if flag == "--geometry" || flag == "-g" => { - json!({ "geometry": geometry }) - } - _ => { - return Err(format!( - "{command} accepts an optional --geometry \"x,y WxH\" ({})", - usage() - )); - } - }, - "toggle-floating" - | "toggle-fullscreen" - | "toggle-maximized" - | "toggle-layout" - | "toggle-overview" - | "toggle-keybinding-guide" - | "cycle-column-width" - | "center-column" - | "consume" - | "expel" - | "close" - | "get-focused-window" - | "get-windows" - | "get-idle-inhibition" - | "get-workspaces" - | "get-outputs" - | "outputs" - | "toggle-display-mode" - | "output-profiles" - | "output-confirm" - | "output-revert" - | "reload-config" - | "exit" - | "request-logout" - | "workspace-back-and-forth" - | "focus-last-window" - | "focus-mru-next" - | "focus-mru-previous" => { - 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 media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-layout \n feresectl toggle-display-mode\n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl xwayland \n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() -} - #[cfg(test)] mod tests { use super::*; @@ -282,7 +158,7 @@ mod tests { assert_eq!(ferese_ipc::xwayland::STATUS_COMMAND, "xwayland-status"); assert_eq!(ferese_ipc::xwayland::RETRY_COMMAND, "xwayland-retry"); - assert!(usage().contains("feresectl xwayland ")); + assert!(Cli::try_parse_from(["feresectl", "xwayland", "status"]).is_ok()); } #[test] diff --git a/feresectl/src/output.rs b/feresectl/src/output.rs new file mode 100644 index 0000000..1f53433 --- /dev/null +++ b/feresectl/src/output.rs @@ -0,0 +1,97 @@ +use std::error::Error; +use std::io::{self, Write}; + +use serde_json::Value; + +pub fn print(value: &Value, json: bool) -> Result<(), Box> { + let mut stdout = io::stdout().lock(); + if json { + writeln!(stdout, "{}", serde_json::to_string_pretty(value)?)?; + } else { + write_plain(&mut stdout, value, 0)?; + } + stdout.flush()?; + Ok(()) +} + +fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Result<()> { + match value { + Value::Object(fields) => { + for (key, value) in fields { + write!(writer, "{:indent$}{key}:", "")?; + if value.is_object() || value.is_array() { + writeln!(writer)?; + write_plain(writer, value, indent + 2)?; + } else { + writeln!(writer, " {}", scalar(value))?; + } + } + } + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + if index > 0 && item.is_object() { + writeln!(writer)?; + } + write_plain(writer, item, indent)?; + } + } + _ => writeln!(writer, "{:indent$}{}", "", scalar(value))?, + } + Ok(()) +} + +fn scalar(value: &Value) -> String { + match value { + Value::Null => "none".into(), + Value::String(text) => text.clone(), + value => value.to_string(), + } +} + +/// Return false when a downstream consumer closes stdout. +pub fn write_json_line(writer: &mut impl Write, value: &impl serde::Serialize) -> Result> { + let line = serde_json::to_vec(value)?; + match writer + .write_all(&line) + .and_then(|()| writer.write_all(b"\n")) + .and_then(|()| writer.flush()) + { + Ok(()) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(false), + Err(error) => Err(error.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn plain_output_has_readable_fields_without_json_quotes() { + let mut bytes = Vec::new(); + write_plain( + &mut bytes, + &serde_json::json!([{"id": 7, "title": "Terminal", "focused": true}]), + 0, + ) + .unwrap(); + assert_eq!( + String::from_utf8(bytes).unwrap(), + "focused: true\nid: 7\ntitle: Terminal\n" + ); + } + #[test] + fn event_output_is_one_json_value_per_line() { + let mut bytes = Vec::new(); + for value in [ + serde_json::json!({"title": "a\nb"}), + serde_json::json!({"locked": true}), + ] { + assert!(write_json_line(&mut bytes, &value).unwrap()); + } + let text = String::from_utf8(bytes).unwrap(); + assert_eq!(text.lines().count(), 2); + for line in text.lines() { + serde_json::from_str::(line).unwrap(); + } + } +} diff --git a/feresectl/tests/cli.rs b/feresectl/tests/cli.rs new file mode 100644 index 0000000..1a5b308 --- /dev/null +++ b/feresectl/tests/cli.rs @@ -0,0 +1,165 @@ +use std::os::unix::net::UnixListener; +use std::path::Path; +use std::process::{Command, Output}; +use std::thread; + +use ferese_ipc::{Request, Response, read_frame, write_frame}; +use serde_json::json; + +fn ctl(args: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_feresectl")); + command + .args(args) + .env_remove("FERESE_SOCKET") + .env_remove("XDG_RUNTIME_DIR"); + command +} + +fn serve(path: &Path, expected: &'static str, value: serde_json::Value) -> thread::JoinHandle<()> { + let listener = UnixListener::bind(path).unwrap(); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let request: Request = read_frame(&mut stream).unwrap(); + assert_eq!(request.command, expected); + write_frame(&mut stream, &Response::success(request.id, value)).unwrap(); + }) +} + +fn success(output: Output) -> String { + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() { + for args in [ + &["--help"][..], + &["theme", "--help"], + &["media", "--help"], + &["xwayland", "--help"], + &["screenshot", "--help"], + ] { + let text = success(ctl(args).output().unwrap()); + assert!(text.contains("Usage:")); + assert!(!text.contains("get-outputs")); + } + let output = ctl(&["widows"]).output().unwrap(); + assert_eq!(output.status.code(), Some(2)); + let error = String::from_utf8(output.stderr).unwrap(); + assert!(error.contains("windows"), "{error}"); +} + +#[test] +fn explicit_socket_targets_the_nested_instance_for_queries_and_theme_commands() { + let directory = tempfile::tempdir().unwrap(); + let nested = directory.path().join("nested.sock"); + let host = directory.path().join("host.sock"); + let host_listener = UnixListener::bind(&host).unwrap(); + host_listener.set_nonblocking(true).unwrap(); + for args in [ + vec!["--socket", nested.to_str().unwrap(), "-j", "windows"], + vec!["theme", "mode", "dark", "--socket", nested.to_str().unwrap(), "--json"], + ] { + let worker = serve( + &nested, + if args[0] == "theme" { + "theme-set-mode" + } else { + "get-windows" + }, + json!({"instance": "nested"}), + ); + let output = success(ctl(&args).env("FERESE_SOCKET", &host).output().unwrap()); + assert_eq!( + serde_json::from_str::(&output).unwrap(), + json!({"instance": "nested"}) + ); + worker.join().unwrap(); + std::fs::remove_file(&nested).unwrap(); + assert_eq!( + host_listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } +} + +#[test] +fn socket_environment_and_runtime_fallback_use_the_same_query_path() { + let directory = tempfile::tempdir().unwrap(); + std::fs::create_dir(directory.path().join("ferese")).unwrap(); + let socket = directory.path().join("ferese/control.sock"); + for environment in ["FERESE_SOCKET", "XDG_RUNTIME_DIR"] { + let worker = serve(&socket, "outputs", json!([{"name": "nested", "enabled": true}])); + let target = if environment == "FERESE_SOCKET" { + socket.as_path() + } else { + directory.path() + }; + let output = success(ctl(&["outputs"]).env(environment, target).output().unwrap()); + assert!(output.contains("name: nested")); + assert!(!output.contains('"')); + worker.join().unwrap(); + std::fs::remove_file(&socket).unwrap(); + } +} + +#[test] +fn legacy_queries_are_hidden_aliases_with_an_explicit_json_flag() { + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("control.sock"); + let worker = serve(&socket, "get-workspaces", json!([])); + let output = ctl(&["--socket", socket.to_str().unwrap(), "get-workspaces", "-j"]) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&output.stderr).contains("deprecated; use workspaces")); + assert_eq!(success(output).trim(), "[]"); + worker.join().unwrap(); +} + +#[test] +fn event_stream_uses_the_selected_socket_and_prints_complete_json_lines() { + use ferese_ipc::events::{Change, Event, VERSION}; + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("events.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let event = Event { + version: VERSION, + generation: 7, + last: true, + change: Change::LockChanged { + lock: ferese_ipc::events::LockState { + phase: "locked".into(), + sleeping: false, + }, + }, + }; + let expected = event.clone(); + let (stop, stopped) = std::sync::mpsc::channel(); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request: Request = read_frame(&mut stream).unwrap(); + assert_eq!(request.command, "event-stream"); + assert_eq!(request.args["version"], VERSION); + write_frame(&mut stream, &Response::success(request.id, json!({"version": VERSION}))).unwrap(); + write_frame(&mut stream, &event).unwrap(); + stopped.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + }); + let mut child = ctl(&["event-stream", "--socket", socket.to_str().unwrap()]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut line = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap(), expected); + child.kill().unwrap(); + child.wait().unwrap(); + stop.send(()).unwrap(); + worker.join().unwrap(); +} diff --git a/packaging/config.kdl b/packaging/config.kdl index e112990..532f268 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -209,7 +209,7 @@ lock-screen { // idle-inhibit { fullscreen-playback #false; } // window-rule app-id="org.example.Presentation" idle-inhibit="visible" -// Find display names with feresectl get-outputs, then adjust and uncomment. +// Find display names with feresectl outputs, then adjust and uncomment. // output-profile "docked" { // output "HDMI-A-1" scale=1.5 { position 0 0; } // output "eDP-1" scale=1.5 { position 2560 0; } diff --git a/packaging/ferese-screenshot b/packaging/ferese-screenshot index 332adba..6b6aee0 100755 --- a/packaging/ferese-screenshot +++ b/packaging/ferese-screenshot @@ -44,7 +44,7 @@ elif [[ $mode == full ]]; then # Never fall back to an all-monitor capture when IPC is unavailable. # This is the only mode that needs the output list; the editor is sized # from the captured image instead. - geometry=$(feresectl get-outputs | python3 -c ' + geometry=$(feresectl -j outputs | python3 -c ' import json, sys outputs = [output for output in json.load(sys.stdin) if output.get("enabled")] focused = [output for output in outputs if output.get("focused")] diff --git a/scripts/soak-native.sh b/scripts/soak-native.sh index 0f58c07..a91968b 100755 --- a/scripts/soak-native.sh +++ b/scripts/soak-native.sh @@ -130,7 +130,7 @@ wait_for_focus() { local focused for ((attempt = 0; attempt < 40; attempt += 1)); do - focused="$("$feresectl_program" get-focused-window 2>/dev/null || true)" + focused="$("$feresectl_program" -j focused-window 2>/dev/null || true)" if [[ "$focused" != "null" && -n "$focused" ]]; then return 0 fi diff --git a/scripts/tests/test_blurred_popup_isolated.py b/scripts/tests/test_blurred_popup_isolated.py index c92b56a..275da6d 100644 --- a/scripts/tests/test_blurred_popup_isolated.py +++ b/scripts/tests/test_blurred_popup_isolated.py @@ -66,7 +66,7 @@ class BlurredPopupTest(unittest.TestCase): self.fail("Blurred popup stopped presenting frames:\n" + log_path.read_text()) time.sleep(.025) control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") - subprocess.run([str(control), "get-outputs"], env=env, check=True, + subprocess.run([str(control), "-j", "outputs"], env=env, check=True, stdout=subprocess.DEVNULL, timeout=5) phases = log_path.read_text().splitlines() for name, count in [("parent", 1), ("open", 3), ("update", 9), ("close", 3)]: diff --git a/scripts/tests/test_floating_isolated.py b/scripts/tests/test_floating_isolated.py index 29a929c..f6ff8aa 100644 --- a/scripts/tests/test_floating_isolated.py +++ b/scripts/tests/test_floating_isolated.py @@ -62,7 +62,7 @@ class FloatingSizeTest(unittest.TestCase): self.assertTrue(sockets, "nested Wayland socket did not appear") childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0])) time.sleep(2) - listed = subprocess.run([str(ctl), "get-outputs"], env=childenv, + listed = subprocess.run([str(ctl), "-j", "outputs"], env=childenv, check=True, capture_output=True, timeout=10, text=True) enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")] if not enabled: diff --git a/scripts/tests/test_floating_placement_isolated.py b/scripts/tests/test_floating_placement_isolated.py index 2b00e02..5a650e1 100644 --- a/scripts/tests/test_floating_placement_isolated.py +++ b/scripts/tests/test_floating_placement_isolated.py @@ -68,7 +68,7 @@ class FloatingPlacementTest(unittest.TestCase): return child def command(*args): - return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), "-j", *args], env=env, timeout=5)) with (root / "compositor.log").open("w") as log: try: diff --git a/scripts/tests/test_focus_history_isolated.py b/scripts/tests/test_focus_history_isolated.py index 682bebc..f65756c 100644 --- a/scripts/tests/test_focus_history_isolated.py +++ b/scripts/tests/test_focus_history_isolated.py @@ -40,7 +40,7 @@ class FocusHistoryTest(unittest.TestCase): def command(*args): return json.loads(subprocess.check_output( - [str(repo / "target/debug/feresectl"), *args], env=env, timeout=5 + [str(repo / "target/debug/feresectl"), "-j", *args], env=env, timeout=5 )) def wait_for(predicate): diff --git a/scripts/tests/test_idle_inhibition_isolated.py b/scripts/tests/test_idle_inhibition_isolated.py index 339f9a6..1cb7a86 100644 --- a/scripts/tests/test_idle_inhibition_isolated.py +++ b/scripts/tests/test_idle_inhibition_isolated.py @@ -116,7 +116,7 @@ def private_checks(): env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock"))) def call(*args): - return json.loads(subprocess.check_output([str(CTL), *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(CTL), "-j", *args], env=env, timeout=5)) def inhibited(expected): def matches(): diff --git a/scripts/tests/test_media_isolated.py b/scripts/tests/test_media_isolated.py index f2b8dd5..5296c32 100644 --- a/scripts/tests/test_media_isolated.py +++ b/scripts/tests/test_media_isolated.py @@ -151,7 +151,7 @@ def private_checks(): wait(lambda: (runtime / 'ferese/control.sock').exists()) def call(*args): - return json.loads(subprocess.check_output([str(ctl), *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(ctl), "-j", *args], env=env, timeout=5)) def request(command, args): payload = json.dumps({'version': 1, 'id': 17, 'type': 'command', diff --git a/scripts/tests/test_overview_damage_isolated.py b/scripts/tests/test_overview_damage_isolated.py index c44afc0..ec22091 100644 --- a/scripts/tests/test_overview_damage_isolated.py +++ b/scripts/tests/test_overview_damage_isolated.py @@ -51,7 +51,7 @@ class OverviewDamageTest(unittest.TestCase): return child def command(*args): - return json.loads(subprocess.check_output([str(control), *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(control), "-j", *args], env=env, timeout=5)) def wait_for(predicate, seconds=12): deadline = time.monotonic() + seconds diff --git a/scripts/tests/test_overview_previews_isolated.py b/scripts/tests/test_overview_previews_isolated.py index 3a7097a..5cdea5b 100644 --- a/scripts/tests/test_overview_previews_isolated.py +++ b/scripts/tests/test_overview_previews_isolated.py @@ -55,7 +55,7 @@ class OverviewPreviews(unittest.TestCase): log_path = root / "compositor.log" def call(*args): - return subprocess.check_output([str(ctl), *args], env=env, timeout=10) + return subprocess.check_output([str(ctl), "-j", *args], env=env, timeout=10) def wait_for(predicate): deadline = time.monotonic() + 10 diff --git a/scripts/tests/test_presentation_isolated.py b/scripts/tests/test_presentation_isolated.py index 9f06e78..4798706 100644 --- a/scripts/tests/test_presentation_isolated.py +++ b/scripts/tests/test_presentation_isolated.py @@ -68,7 +68,7 @@ class Presentation(unittest.TestCase): env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock"))) def call(*args): - return subprocess.check_output([str(REPO / "target/debug/feresectl"), *args], env=env, timeout=10) + return subprocess.check_output([str(REPO / "target/debug/feresectl"), "-j", *args], env=env, timeout=10) def windows(count): deadline = time.monotonic() + 10 diff --git a/scripts/tests/test_resize_dependencies_isolated.py b/scripts/tests/test_resize_dependencies_isolated.py index 8e91888..fbb480d 100644 --- a/scripts/tests/test_resize_dependencies_isolated.py +++ b/scripts/tests/test_resize_dependencies_isolated.py @@ -61,7 +61,7 @@ class ResizeDependencies(unittest.TestCase): client_env = dict(env, WAYLAND_DISPLAY=str(socket)) def call(*args): - return json.loads(subprocess.check_output([str(ctl), *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(ctl), "-j", *args], env=env, timeout=5)) def action(*args): subprocess.run([str(ctl), *args], env=env, check=True, capture_output=True, timeout=5) diff --git a/scripts/tests/test_screenshot.py b/scripts/tests/test_screenshot.py index e13bc08..cc54576 100644 --- a/scripts/tests/test_screenshot.py +++ b/scripts/tests/test_screenshot.py @@ -20,7 +20,7 @@ FOCUSED_GEOMETRY = '1920,0 2560x1440' FARESECTL = '''#!/bin/bash case "$1" in - get-outputs) cat "$TEST_ROOT/outputs.json" ;; + -j) [[ "$2" == outputs && "$#" == 2 ]] || exit 1; cat "$TEST_ROOT/outputs.json" ;; screenshot) printf '%s\\n' "$@" >> "$TEST_ROOT/captures" cat "$TEST_ROOT/capture.png" diff --git a/scripts/tests/test_theme_isolated.py b/scripts/tests/test_theme_isolated.py index 3f1be5c..bde26e2 100644 --- a/scripts/tests/test_theme_isolated.py +++ b/scripts/tests/test_theme_isolated.py @@ -43,7 +43,7 @@ class ThemeTest(unittest.TestCase): return child def command(*args): - return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), "-j", *args], env=env, timeout=5)) def until(predicate, seconds=5): deadline = time.monotonic() + seconds diff --git a/scripts/tests/test_unmapped_toplevel_isolated.py b/scripts/tests/test_unmapped_toplevel_isolated.py index e6437e7..f38af0f 100644 --- a/scripts/tests/test_unmapped_toplevel_isolated.py +++ b/scripts/tests/test_unmapped_toplevel_isolated.py @@ -100,7 +100,7 @@ class UnmappedToplevelTest(unittest.TestCase): deadline = time.monotonic() + 3 while True: result = subprocess.run( - [str(ctl_binary), "get-focused-window"], + [str(ctl_binary), "-j", "focused-window"], env=env, capture_output=True, text=True, @@ -131,7 +131,7 @@ class UnmappedToplevelTest(unittest.TestCase): deadline = time.monotonic() + 3 while True: remaining = json.loads(subprocess.check_output( - [str(ctl_binary), "get-windows"], env=env, text=True + [str(ctl_binary), "-j", "windows"], env=env, text=True )) if not remaining: break @@ -142,7 +142,7 @@ class UnmappedToplevelTest(unittest.TestCase): subprocess.run([str(ctl_binary), "focus-last-window"], env=env, capture_output=True, check=True) focused = json.loads(subprocess.check_output( - [str(ctl_binary), "get-focused-window"], env=env, text=True + [str(ctl_binary), "-j", "focused-window"], env=env, text=True )) self.assertIsNone(focused, "focus history must not restore a destroyed window") deadline = time.monotonic() + 3 diff --git a/scripts/tests/test_window_capture_isolated.py b/scripts/tests/test_window_capture_isolated.py index 9659b03..5c8240a 100644 --- a/scripts/tests/test_window_capture_isolated.py +++ b/scripts/tests/test_window_capture_isolated.py @@ -69,7 +69,7 @@ class WindowCapture(unittest.TestCase): env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock"))) def call(*args): - return subprocess.check_output([str(CTL), *args], env=env, timeout=10) + return subprocess.check_output([str(CTL), "-j", *args], env=env, timeout=10) def windows(count): deadline = time.monotonic() + 10 diff --git a/scripts/tests/test_workspace_history_isolated.py b/scripts/tests/test_workspace_history_isolated.py index 14825d2..dbc9033 100644 --- a/scripts/tests/test_workspace_history_isolated.py +++ b/scripts/tests/test_workspace_history_isolated.py @@ -41,7 +41,7 @@ class WorkspaceHistoryTest(unittest.TestCase): def command(*args): return json.loads(subprocess.check_output( - [str(repo / "target/debug/feresectl"), *args], env=env, timeout=5 + [str(repo / "target/debug/feresectl"), "-j", *args], env=env, timeout=5 )) def active():