Modernize feresectl and add bounded IPC event streaming
This commit is contained in:
1 parent
cfdd2a148a
commit
97026a0db7
39 files changed
+1671
-254
No files matched your search
Generated
+111
@@ -191,12 +191,56 @@ dependencies = [
|
|||||||
"unicode-width 0.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "anstyle"
|
name = "anstyle"
|
||||||
version = "1.0.14"
|
version = "1.0.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
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]]
|
[[package]]
|
||||||
name = "appendlist"
|
name = "appendlist"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -826,6 +870,46 @@ dependencies = [
|
|||||||
"libloading",
|
"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]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -911,6 +995,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -1854,10 +1944,13 @@ dependencies = [
|
|||||||
name = "feresectl"
|
name = "feresectl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"clap",
|
||||||
"ferese-config",
|
"ferese-config",
|
||||||
"ferese-ipc",
|
"ferese-ipc",
|
||||||
"libc",
|
"libc",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3093,6 +3186,12 @@ dependencies = [
|
|||||||
"windows-sys 0.48.0",
|
"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]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -4280,6 +4379,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -6277,6 +6382,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.26.1"
|
version = "1.26.1"
|
||||||
|
|||||||
+165
-1
@@ -43,6 +43,7 @@ struct IpcCall {
|
|||||||
owner: u64,
|
owner: u64,
|
||||||
request: Request,
|
request: Request,
|
||||||
response: SyncSender<Response>,
|
response: SyncSender<Response>,
|
||||||
|
events: Option<SyncSender<Arc<ferese_ipc::events::Event>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -108,6 +109,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
let call = match event {
|
let call = match event {
|
||||||
IpcEvent::Call(call) => call,
|
IpcEvent::Call(call) => call,
|
||||||
IpcEvent::Closed(owner) => {
|
IpcEvent::Closed(owner) => {
|
||||||
|
state.ipc_events.remove(owner);
|
||||||
for request in state.screenshot.terminate_owner(owner) {
|
for request in state.screenshot.terminate_owner(owner) {
|
||||||
state
|
state
|
||||||
.pending_screencopies
|
.pending_screencopies
|
||||||
@@ -126,7 +128,25 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
return;
|
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(
|
let _ = call.response.try_send(Response::error(
|
||||||
call.request.id,
|
call.request.id,
|
||||||
"access_denied",
|
"access_denied",
|
||||||
@@ -449,6 +469,8 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (response, receiver) = sync_channel(1);
|
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 exit_requested = request.command == "exit";
|
||||||
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window");
|
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window");
|
||||||
let call = IpcEvent::Call(IpcCall {
|
let call = IpcEvent::Call(IpcCall {
|
||||||
@@ -456,6 +478,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
|
|||||||
owner,
|
owner,
|
||||||
request,
|
request,
|
||||||
response,
|
response,
|
||||||
|
events: event_stream_requested.then_some(events),
|
||||||
});
|
});
|
||||||
if sender.try_send(call).is_err() {
|
if sender.try_send(call).is_err() {
|
||||||
tracing::warn!("disconnecting IPC client because the request queue is full");
|
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 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 let Err(error) = write_frame(&mut stream, &response) {
|
||||||
if screenshot_requested {
|
if screenshot_requested {
|
||||||
discard_undelivered_screenshot(&response);
|
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");
|
tracing::debug!(%error, "IPC client disconnected before receiving its response");
|
||||||
return;
|
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 {
|
if exit_accepted {
|
||||||
// Acknowledge before stopping so feresectl never races process exit.
|
// Acknowledge before stopping so feresectl never races process exit.
|
||||||
signal.stop();
|
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> {
|
fn u32_arg(args: &Value, key: &str) -> Result<u32, CommandError> {
|
||||||
args[key]
|
args[key]
|
||||||
.as_u64()
|
.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 {
|
fn focused_window_json(&self) -> Value {
|
||||||
self.focused_window
|
self.focused_window
|
||||||
.map(|window| json!({ "id": window.0 }))
|
.map(|window| json!({ "id": window.0 }))
|
||||||
@@ -1743,6 +1859,54 @@ mod tests {
|
|||||||
assert!(try_acquire_connection(&active).is_some());
|
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 {
|
fn unique_test_directory(label: &str) -> PathBuf {
|
||||||
let nonce = std::time::SystemTime::now()
|
let nonce = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ mod idle_inhibition;
|
|||||||
mod input;
|
mod input;
|
||||||
mod input_capture;
|
mod input_capture;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
|
mod ipc_events;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod monitor_identity;
|
mod monitor_identity;
|
||||||
mod output_policy;
|
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");
|
warn!(%error, "could not schedule wallpaper upload retry");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.publish_ipc_events();
|
||||||
|
|
||||||
// Clients need registry/sync/configure replies even when no frame is drawn.
|
// Clients need registry/sync/configure replies even when no frame is drawn.
|
||||||
if let Err(error) = state.display_handle.flush_clients() {
|
if let Err(error) = state.display_handle.flush_clients() {
|
||||||
warn!(%error, "failed to flush Wayland clients");
|
warn!(%error, "failed to flush Wayland clients");
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ impl crate::Ferese {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
self.ipc_events.config_revision = self.ipc_events.config_revision.wrapping_add(1);
|
||||||
tracing::info!("configuration reloaded live");
|
tracing::info!("configuration reloaded live");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,19 @@ pub(crate) struct Lock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub(crate) fn active(&self) -> bool {
|
||||||
!matches!(self.lifecycle, Lifecycle::Unlocked)
|
!matches!(self.lifecycle, Lifecycle::Unlocked)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -689,3 +689,39 @@ fn lock_input_preserves_owner_click_grabs_and_rejects_retired_surfaces() {
|
|||||||
assert_eq!(pointer.current_focus(), None);
|
assert_eq!(pointer.current_focus(), None);
|
||||||
assert_eq!(fixture.state.seat.get_keyboard().unwrap().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")
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -398,6 +398,7 @@ pub struct Ferese {
|
|||||||
pub(crate) lock_idle: IdleSettings,
|
pub(crate) lock_idle: IdleSettings,
|
||||||
pub(crate) config_source: Option<String>,
|
pub(crate) config_source: Option<String>,
|
||||||
pub(crate) config_sections: Option<serde_json::Value>,
|
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) config_worker: Option<crate::reload::Worker>,
|
||||||
pub(crate) theme_engine: crate::theme::Engine,
|
pub(crate) theme_engine: crate::theme::Engine,
|
||||||
pub start_time: Instant,
|
pub start_time: Instant,
|
||||||
@@ -646,6 +647,7 @@ impl Ferese {
|
|||||||
let mut state = Self {
|
let mut state = Self {
|
||||||
session_lock_state,
|
session_lock_state,
|
||||||
session_lock: crate::session_lock::Lock::default(),
|
session_lock: crate::session_lock::Lock::default(),
|
||||||
|
ipc_events: crate::ipc_events::Subscribers::default(),
|
||||||
lock_idle: config.lock_idle,
|
lock_idle: config.lock_idle,
|
||||||
config_source: None,
|
config_source: None,
|
||||||
config_sections: None,
|
config_sections: None,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ use std::{fmt, io};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub mod events;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
pub mod socket;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod xwayland;
|
pub mod xwayland;
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,10 +46,7 @@ impl Snapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn socket_path() -> io::Result<PathBuf> {
|
pub fn socket_path() -> io::Result<PathBuf> {
|
||||||
std::env::var_os("XDG_RUNTIME_DIR")
|
crate::socket::resolve(None)
|
||||||
.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"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Connection {
|
pub struct Connection {
|
||||||
@@ -59,7 +56,11 @@ pub struct Connection {
|
|||||||
|
|
||||||
impl Connection {
|
impl Connection {
|
||||||
pub fn connect() -> io::Result<Self> {
|
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_write_timeout(Some(Duration::from_secs(2)))?;
|
||||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||||
Ok(Self { stream, id: 0 })
|
Ok(Self { stream, id: 0 })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
- [Lock screen](locking.md): change its appearance, set up automatic locking and configure display sleep.
|
- [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.
|
- [Screen sharing and recording](screen-sharing.md): choose a source and stop sharing.
|
||||||
- [Desktop portals](portals.md): services available to applications.
|
- [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
|
## Develop Ferese
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
These settings control automatic inhibition. They do not override an
|
||||||
application's own Wayland or portal request, and they do not block manual
|
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.
|
inhibition state and discovered players. Changes take effect on config reload.
|
||||||
|
|
||||||
## Optional session protocols
|
## Optional session protocols
|
||||||
|
|||||||
+125
@@ -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.
|
||||||
@@ -37,7 +37,7 @@ The local probe can also be run independently against a disposable Ferese sessio
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
WAYLAND_DISPLAY=wayland-1 /path/to/ferese-malformed-client
|
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.
|
Do not point the malformed-client probe at the host compositor socket.
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ license.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
ferese-config = { path = "../crates/ferese-config" }
|
ferese-config = { path = "../crates/ferese-config" }
|
||||||
ferese-ipc = { path = "../crates/ferese-ipc" }
|
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-226
@@ -1,127 +1,130 @@
|
|||||||
mod autostart;
|
mod autostart;
|
||||||
|
mod cli;
|
||||||
|
mod output;
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::PathBuf;
|
use std::path::Path;
|
||||||
use std::{env, fs, io};
|
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 ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
if env::args().nth(1).as_deref() == Some("autostart") {
|
let cli = Cli::parse();
|
||||||
if env::args().len() != 2 {
|
if matches!(cli.command, Command::Autostart) {
|
||||||
return Err("usage: feresectl autostart".into());
|
|
||||||
}
|
|
||||||
return autostart::run();
|
return autostart::run();
|
||||||
}
|
}
|
||||||
if env::args().nth(1).as_deref() == Some("theme") {
|
for alias in env::args().skip(1).filter(|arg| {
|
||||||
return theme_command(env::args().skip(2).collect());
|
matches!(
|
||||||
}
|
arg.as_str(),
|
||||||
if env::args().nth(1).as_deref() == Some("xwayland") {
|
"get-outputs"
|
||||||
return xwayland_command(env::args().skip(2).collect());
|
| "get-windows"
|
||||||
}
|
| "get-workspaces"
|
||||||
let (command, args) = parse_args(env::args().skip(1))?;
|
| "get-focused-window"
|
||||||
let request = Request {
|
| "get-idle-inhibition"
|
||||||
version: VERSION,
|
| "get-session-state"
|
||||||
id: 1,
|
| "get-keybindings"
|
||||||
kind: "command".to_owned(),
|
)
|
||||||
command: command.clone(),
|
}) {
|
||||||
args,
|
eprintln!(
|
||||||
};
|
"feresectl: {alias} is deprecated; use {}",
|
||||||
let mut stream = UnixStream::connect(socket_path()?)?;
|
alias.trim_start_matches("get-")
|
||||||
|
|
||||||
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))?
|
|
||||||
);
|
);
|
||||||
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>> {
|
fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result<Value, Box<dyn Error>> {
|
||||||
let mut connection = ferese_ipc::theme::Connection::connect()?;
|
write_frame(
|
||||||
match args.as_slice() {
|
stream,
|
||||||
[command] if command == "get" || command == "status" => {
|
&Request {
|
||||||
println!(
|
version: VERSION,
|
||||||
"{}",
|
id: 1,
|
||||||
serde_json::to_string_pretty(&connection.get(ferese_config::families::builtins)?)?
|
kind: "command".into(),
|
||||||
);
|
command: command.into(),
|
||||||
}
|
args,
|
||||||
[command] if command == "subscribe" => {
|
},
|
||||||
|
)?;
|
||||||
|
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<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 snapshot = connection.get(ferese_config::families::builtins)?;
|
||||||
|
let mut stdout = io::stdout().lock();
|
||||||
loop {
|
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)?;
|
snapshot = connection.watch(snapshot.revision, ferese_config::families::builtins)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
[command, mode] if command == "mode" && matches!(mode.as_str(), "light" | "dark" | "auto") => {
|
ThemeAction::Mode { mode } => connection.call(
|
||||||
println!(
|
"theme-set-mode",
|
||||||
"{}",
|
json!({"mode": mode.to_possible_value().unwrap().get_name()}),
|
||||||
serde_json::to_string_pretty(&connection.call("theme-set-mode", json!({"mode": mode}))?)?
|
)?,
|
||||||
);
|
ThemeAction::Preview { path } => connection.call(
|
||||||
}
|
"theme-preview",
|
||||||
[command, path] if command == "preview" => {
|
json!({
|
||||||
let source = fs::read_to_string(path)?;
|
"source": fs::read_to_string(path)?, "directory": path.canonicalize()?.parent(),
|
||||||
println!(
|
}),
|
||||||
"{}",
|
)?,
|
||||||
serde_json::to_string_pretty(&connection.call(
|
};
|
||||||
"theme-preview",
|
output::print(&result, json_output)
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn xwayland_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
|
fn event_stream(socket: &Path) -> Result<(), Box<dyn Error>> {
|
||||||
use ferese_ipc::xwayland::{RETRY_COMMAND, STATUS_COMMAND, Status};
|
let mut stream = UnixStream::connect(socket)?;
|
||||||
|
request(
|
||||||
let command = match args.as_slice() {
|
&mut stream,
|
||||||
[command] if command == "status" => STATUS_COMMAND,
|
"event-stream",
|
||||||
[command] if command == "retry" => RETRY_COMMAND,
|
json!({"version": ferese_ipc::events::VERSION}),
|
||||||
_ => return Err("usage: feresectl xwayland <status|retry>".into()),
|
)?;
|
||||||
};
|
let mut stdout = io::stdout().lock();
|
||||||
|
loop {
|
||||||
let request = Request {
|
let event: ferese_ipc::events::Event = read_frame(&mut stream)?;
|
||||||
version: VERSION,
|
event.validate()?;
|
||||||
id: 1,
|
if !output::write_json_line(&mut stdout, &event)? {
|
||||||
kind: "command".to_owned(),
|
return Ok(());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let status = Status::from_value(response.result.unwrap_or(Value::Null))?;
|
#[cfg(test)]
|
||||||
if command == RETRY_COMMAND {
|
fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value), String> {
|
||||||
println!(
|
let cli = Cli::try_parse_from(std::iter::once("feresectl".to_owned()).chain(args)).map_err(|e| e.to_string())?;
|
||||||
"X11 retry requested; current state: {}",
|
cli.command
|
||||||
serde_json::to_string(&status.state)?
|
.ipc()
|
||||||
);
|
.map(|(command, args)| (command.into(), args))
|
||||||
}
|
.ok_or_else(|| "not an IPC command".into())
|
||||||
println!("{}", serde_json::to_string_pretty(&status)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The compositor stages the PNG in a private directory and replies with its
|
// 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(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -282,7 +158,7 @@ mod tests {
|
|||||||
assert_eq!(ferese_ipc::xwayland::STATUS_COMMAND, "xwayland-status");
|
assert_eq!(ferese_ipc::xwayland::STATUS_COMMAND, "xwayland-status");
|
||||||
assert_eq!(ferese_ipc::xwayland::RETRY_COMMAND, "xwayland-retry");
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -209,7 +209,7 @@ lock-screen {
|
|||||||
// idle-inhibit { fullscreen-playback #false; }
|
// idle-inhibit { fullscreen-playback #false; }
|
||||||
// window-rule app-id="org.example.Presentation" idle-inhibit="visible"
|
// 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-profile "docked" {
|
||||||
// output "HDMI-A-1" scale=1.5 { position 0 0; }
|
// output "HDMI-A-1" scale=1.5 { position 0 0; }
|
||||||
// output "eDP-1" scale=1.5 { position 2560 0; }
|
// output "eDP-1" scale=1.5 { position 2560 0; }
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ elif [[ $mode == full ]]; then
|
|||||||
# Never fall back to an all-monitor capture when IPC is unavailable.
|
# 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
|
# This is the only mode that needs the output list; the editor is sized
|
||||||
# from the captured image instead.
|
# from the captured image instead.
|
||||||
geometry=$(feresectl get-outputs | python3 -c '
|
geometry=$(feresectl -j outputs | python3 -c '
|
||||||
import json, sys
|
import json, sys
|
||||||
outputs = [output for output in json.load(sys.stdin) if output.get("enabled")]
|
outputs = [output for output in json.load(sys.stdin) if output.get("enabled")]
|
||||||
focused = [output for output in outputs if output.get("focused")]
|
focused = [output for output in outputs if output.get("focused")]
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ wait_for_focus() {
|
|||||||
local focused
|
local focused
|
||||||
|
|
||||||
for ((attempt = 0; attempt < 40; attempt += 1)); do
|
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
|
if [[ "$focused" != "null" && -n "$focused" ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class BlurredPopupTest(unittest.TestCase):
|
|||||||
self.fail("Blurred popup stopped presenting frames:\n" + log_path.read_text())
|
self.fail("Blurred popup stopped presenting frames:\n" + log_path.read_text())
|
||||||
time.sleep(.025)
|
time.sleep(.025)
|
||||||
control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
|
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)
|
stdout=subprocess.DEVNULL, timeout=5)
|
||||||
phases = log_path.read_text().splitlines()
|
phases = log_path.read_text().splitlines()
|
||||||
for name, count in [("parent", 1), ("open", 3), ("update", 9), ("close", 3)]:
|
for name, count in [("parent", 1), ("open", 3), ("update", 9), ("close", 3)]:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class FloatingSizeTest(unittest.TestCase):
|
|||||||
self.assertTrue(sockets, "nested Wayland socket did not appear")
|
self.assertTrue(sockets, "nested Wayland socket did not appear")
|
||||||
childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0]))
|
childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0]))
|
||||||
time.sleep(2)
|
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)
|
check=True, capture_output=True, timeout=10, text=True)
|
||||||
enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")]
|
enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")]
|
||||||
if not enabled:
|
if not enabled:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class FloatingPlacementTest(unittest.TestCase):
|
|||||||
return child
|
return child
|
||||||
|
|
||||||
def command(*args):
|
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:
|
with (root / "compositor.log").open("w") as log:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class FocusHistoryTest(unittest.TestCase):
|
|||||||
|
|
||||||
def command(*args):
|
def command(*args):
|
||||||
return json.loads(subprocess.check_output(
|
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):
|
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")))
|
env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")))
|
||||||
|
|
||||||
def call(*args):
|
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 inhibited(expected):
|
||||||
def matches():
|
def matches():
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ def private_checks():
|
|||||||
wait(lambda: (runtime / 'ferese/control.sock').exists())
|
wait(lambda: (runtime / 'ferese/control.sock').exists())
|
||||||
|
|
||||||
def call(*args):
|
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):
|
def request(command, args):
|
||||||
payload = json.dumps({'version': 1, 'id': 17, 'type': 'command',
|
payload = json.dumps({'version': 1, 'id': 17, 'type': 'command',
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class OverviewDamageTest(unittest.TestCase):
|
|||||||
return child
|
return child
|
||||||
|
|
||||||
def command(*args):
|
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):
|
def wait_for(predicate, seconds=12):
|
||||||
deadline = time.monotonic() + seconds
|
deadline = time.monotonic() + seconds
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class OverviewPreviews(unittest.TestCase):
|
|||||||
log_path = root / "compositor.log"
|
log_path = root / "compositor.log"
|
||||||
|
|
||||||
def call(*args):
|
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):
|
def wait_for(predicate):
|
||||||
deadline = time.monotonic() + 10
|
deadline = time.monotonic() + 10
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class Presentation(unittest.TestCase):
|
|||||||
env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
|
env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
|
||||||
|
|
||||||
def call(*args):
|
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):
|
def windows(count):
|
||||||
deadline = time.monotonic() + 10
|
deadline = time.monotonic() + 10
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class ResizeDependencies(unittest.TestCase):
|
|||||||
client_env = dict(env, WAYLAND_DISPLAY=str(socket))
|
client_env = dict(env, WAYLAND_DISPLAY=str(socket))
|
||||||
|
|
||||||
def call(*args):
|
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):
|
def action(*args):
|
||||||
subprocess.run([str(ctl), *args], env=env, check=True, capture_output=True, timeout=5)
|
subprocess.run([str(ctl), *args], env=env, check=True, capture_output=True, timeout=5)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ FOCUSED_GEOMETRY = '1920,0 2560x1440'
|
|||||||
|
|
||||||
FARESECTL = '''#!/bin/bash
|
FARESECTL = '''#!/bin/bash
|
||||||
case "$1" in
|
case "$1" in
|
||||||
get-outputs) cat "$TEST_ROOT/outputs.json" ;;
|
-j) [[ "$2" == outputs && "$#" == 2 ]] || exit 1; cat "$TEST_ROOT/outputs.json" ;;
|
||||||
screenshot)
|
screenshot)
|
||||||
printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
|
printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
|
||||||
cat "$TEST_ROOT/capture.png"
|
cat "$TEST_ROOT/capture.png"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class ThemeTest(unittest.TestCase):
|
|||||||
return child
|
return child
|
||||||
|
|
||||||
def command(*args):
|
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):
|
def until(predicate, seconds=5):
|
||||||
deadline = time.monotonic() + seconds
|
deadline = time.monotonic() + seconds
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class UnmappedToplevelTest(unittest.TestCase):
|
|||||||
deadline = time.monotonic() + 3
|
deadline = time.monotonic() + 3
|
||||||
while True:
|
while True:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[str(ctl_binary), "get-focused-window"],
|
[str(ctl_binary), "-j", "focused-window"],
|
||||||
env=env,
|
env=env,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -131,7 +131,7 @@ class UnmappedToplevelTest(unittest.TestCase):
|
|||||||
deadline = time.monotonic() + 3
|
deadline = time.monotonic() + 3
|
||||||
while True:
|
while True:
|
||||||
remaining = json.loads(subprocess.check_output(
|
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:
|
if not remaining:
|
||||||
break
|
break
|
||||||
@@ -142,7 +142,7 @@ class UnmappedToplevelTest(unittest.TestCase):
|
|||||||
subprocess.run([str(ctl_binary), "focus-last-window"], env=env,
|
subprocess.run([str(ctl_binary), "focus-last-window"], env=env,
|
||||||
capture_output=True, check=True)
|
capture_output=True, check=True)
|
||||||
focused = json.loads(subprocess.check_output(
|
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")
|
self.assertIsNone(focused, "focus history must not restore a destroyed window")
|
||||||
deadline = time.monotonic() + 3
|
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")))
|
env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
|
||||||
|
|
||||||
def call(*args):
|
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):
|
def windows(count):
|
||||||
deadline = time.monotonic() + 10
|
deadline = time.monotonic() + 10
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class WorkspaceHistoryTest(unittest.TestCase):
|
|||||||
|
|
||||||
def command(*args):
|
def command(*args):
|
||||||
return json.loads(subprocess.check_output(
|
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():
|
def active():
|
||||||
|
|||||||
Reference in new issue
Block a user