Modernize feresectl and add bounded IPC event streaming

This commit is contained in:
Samuel Onoja committed 2026-10-07 07:39:27 +01:00
1 parent cfdd2a148a
commit 97026a0db7
39 files changed
+1669 -252

No files matched your search

Generated
+111
View File
@@ -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"
+165 -1
View File
@@ -43,6 +43,7 @@ struct IpcCall {
owner: u64,
request: Request,
response: SyncSender<Response>,
events: Option<SyncSender<Arc<ferese_ipc::events::Event>>>,
}
#[derive(Debug)]
@@ -108,6 +109,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
let call = match event {
IpcEvent::Call(call) => 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<Screen
return;
}
};
if call.request.command.starts_with("input-capture-") && !call.native_portal {
if call.request.command == "event-stream" {
let result = validate_request(&call.request).and_then(|()| {
if call.request.args["version"].as_u64() != Some(u64::from(ferese_ipc::events::VERSION)) {
return Err(CommandError::new(
"unsupported_event_version",
"Unsupported event schema version",
));
}
state
.ipc_events
.subscribe(call.owner, call.events.expect("event stream sender"))
.map_err(|error| CommandError::new("subscriber_limit", error))
});
let response = match result {
Ok(()) => 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<IpcEvent
continue;
}
let (response, receiver) = sync_channel(1);
let event_stream_requested = request.command == "event-stream";
let (events, event_receiver) = sync_channel(crate::ipc_events::QUEUE_CAPACITY);
let exit_requested = request.command == "exit";
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window");
let call = IpcEvent::Call(IpcCall {
@@ -456,6 +478,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
owner,
request,
response,
events: event_stream_requested.then_some(events),
});
if sender.try_send(call).is_err() {
tracing::warn!("disconnecting IPC client because the request queue is full");
@@ -491,6 +514,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
}
};
let exit_accepted = exit_requested && response.error.is_none();
let event_stream_accepted = event_stream_requested && response.error.is_none();
if let Err(error) = write_frame(&mut stream, &response) {
if screenshot_requested {
discard_undelivered_screenshot(&response);
@@ -498,6 +522,11 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
tracing::debug!(%error, "IPC client disconnected before receiving its response");
return;
}
if event_stream_accepted {
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
relay_events(&mut stream, event_receiver);
return;
}
if exit_accepted {
// Acknowledge before stopping so feresectl never races process exit.
signal.stop();
@@ -507,6 +536,41 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
}
}
// Socket I/O stays on the bounded connection worker, never the compositor loop.
fn relay_events(stream: &mut UnixStream, events: std::sync::mpsc::Receiver<Arc<ferese_ipc::events::Event>>) {
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<u32, CommandError> {
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)
+347
View File
@@ -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<Arc<Event>>,
initial: bool,
}
#[derive(Default)]
pub(crate) struct Subscribers {
subscribers: HashMap<u64, Subscriber>,
previous: Option<Snapshot>,
generation: u64,
pub(crate) config_revision: u64,
}
impl Subscribers {
pub(crate) fn subscribe(&mut self, owner: u64, sender: SyncSender<Arc<Event>>) -> 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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();
}
}
}
+3
View File
@@ -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");
+1
View File
@@ -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(())
}
+13
View File
@@ -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)
}
+36
View File
@@ -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")
));
}
+2
View File
@@ -398,6 +398,7 @@ pub struct Ferese {
pub(crate) lock_idle: IdleSettings,
pub(crate) config_source: Option<String>,
pub(crate) config_sections: Option<serde_json::Value>,
pub(crate) ipc_events: crate::ipc_events::Subscribers,
pub(crate) config_worker: Option<crate::reload::Worker>,
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,
+134
View File
@@ -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<String>,
}
#[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<Change> {
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()
);
}
}
+2
View File
@@ -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;
+49
View File
@@ -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<PathBuf> {
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<PathBuf> {
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());
}
}
+6 -5
View File
@@ -46,10 +46,7 @@ impl Snapshot {
}
pub fn socket_path() -> io::Result<PathBuf> {
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<Self> {
let stream = UnixStream::connect(socket_path()?)?;
Self::connect_to(socket_path()?)
}
pub fn connect_to(path: impl AsRef<std::path::Path>) -> io::Result<Self> {
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 })
+1
View File
@@ -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
+1 -1
View File
@@ -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
+125
View File
@@ -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.
+1 -1
View File
@@ -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.
+5
View File
@@ -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
+285
View File
@@ -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<PathBuf>,
#[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<String>,
},
/// 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<MediaAction>,
},
/// 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))
}
}
+100 -224
View File
@@ -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<dyn Error>> {
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<String>) -> Result<(), Box<dyn Error>> {
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)?)?
);
fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result<Value, Box<dyn Error>> {
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());
}
[command] if command == "subscribe" => {
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<dyn Error>> {
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(
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": source, "directory": std::path::Path::new(path).canonicalize()?.parent()})
)?)?
);
}
_ => {
return Err(
"usage: feresectl theme <get|status|subscribe|mode light|mode dark|mode auto|preview PATH>".into(),
);
}
}
Ok(())
json!({
"source": fs::read_to_string(path)?, "directory": path.canonicalize()?.parent(),
}),
)?,
};
output::print(&result, json_output)
}
fn xwayland_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
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 <status|retry>".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<dyn Error>> {
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<Item = String>) -> 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<dyn Error>> {
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() {
"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::<u64>()
.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::<u32>()
.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<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 media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl xwayland <status|retry>\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|get-idle-inhibition|reload-config>".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 <status|retry>"));
assert!(Cli::try_parse_from(["feresectl", "xwayland", "status"]).is_ok());
}
#[test]
+97
View File
@@ -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<dyn Error>> {
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<bool, Box<dyn Error>> {
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::<Value>(line).unwrap();
}
}
}
+165
View File
@@ -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::<serde_json::Value>(&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::<Event>(&line).unwrap(), expected);
child.kill().unwrap();
child.wait().unwrap();
stop.send(()).unwrap();
worker.join().unwrap();
}
+1 -1
View File
@@ -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; }
+1 -1
View File
@@ -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")]
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)]:
+1 -1
View File
@@ -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:
@@ -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:
+1 -1
View File
@@ -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):
@@ -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():
+1 -1
View File
@@ -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',
@@ -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
@@ -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
+1 -1
View File
@@ -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
@@ -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)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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():