use std::cell::RefCell; use std::os::fd::AsRawFd; use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{SyncSender, sync_channel}; use std::time::{Duration, Instant}; use std::{fs, io, thread}; use ferese_core::LayoutMode; use ferese_ipc::{Request, Response, VERSION}; pub(crate) mod activity_owner; mod transport; use ferese_layout::Direction; use serde_json::{Value, json}; use smithay::output::Output; use smithay::reexports::calloop::{EventLoop, LoopHandle, LoopSignal, RegistrationToken, channel, timer}; use smithay::utils::Transform; use crate::Ferese; use crate::config::OutputTransform; use crate::handlers::screenshot::{Action, Geometry, OutputLayout, PartOutcome, PartSender, parse_geometry, plan}; use crate::handlers::screenshot_worker::{Encoded, Job, Worker}; use crate::handlers::{screencopy, screenshot_worker}; const REQUEST_QUEUE_CAPACITY: usize = 128; const MAX_CONNECTIONS: usize = 64; const RESULT_QUEUE_CAPACITY: usize = 8; // The encode thread pushes one result per job, and at most QUEUE_CAPACITY jobs // can be queued while a further one is in flight. Keeping the result queue // larger than that is what stops the worker's send from ever blocking, which // would otherwise wedge it whenever the event loop is busy. const _: () = assert!( RESULT_QUEUE_CAPACITY > crate::handlers::screenshot_worker::QUEUE_CAPACITY + 1, "the result queue must outsize the job queue plus the in-flight job" ); const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL; #[derive(Debug)] pub(crate) struct IpcCall { native_portal: bool, activity: Option<( activity_owner::VerifiedOwner, ferese_ipc::activities::Publish, Option, )>, owner: u64, request: Request, response: SyncSender, events: Option>>, } #[derive(Debug)] pub(crate) enum IpcEvent { Call(Box), Closed(u64), ActivityOwnerExited(ferese_activities::Owner), ActivityDetected( ferese_activities::Owner, Box, Arc, ), } struct IpcConnection { owner: u64, sender: channel::SyncSender, } impl Drop for IpcConnection { fn drop(&mut self) { let _ = self.sender.send(IpcEvent::Closed(self.owner)); } } struct ConnectionPermit { active: Arc, } impl Drop for ConnectionPermit { fn drop(&mut self) { self.active.fetch_sub(1, Ordering::AcqRel); } } #[derive(Debug)] pub(crate) struct IpcSocketGuard { path: PathBuf, device: u64, inode: u64, _instance: Option, } pub(crate) struct Endpoint { pub(crate) path: PathBuf, instance: Option, } impl Endpoint { pub(crate) fn desktop() -> io::Result { Self::desktop_at(ferese_ipc::socket::runtime_default()?) } fn desktop_at(path: PathBuf) -> io::Result { prepare_parent(&path)?; Ok(Self { path, instance: None }) } pub(crate) fn instance() -> io::Result { Self::instance_at(ferese_ipc::socket::runtime_default()?) } fn instance_at(default: PathBuf) -> io::Result { let root = default.parent().expect("runtime control socket has a parent"); prepare_directory(root)?; let instances = root.join("instances"); prepare_directory(&instances)?; let instance = tempfile::Builder::new() .prefix(&format!("{}-", std::process::id())) .permissions(fs::Permissions::from_mode(0o700)) .tempdir_in(instances)?; Ok(Self { path: instance.path().join("control.sock"), instance: Some(instance), }) } } impl Drop for IpcSocketGuard { fn drop(&mut self) { if self.path.symlink_metadata().is_ok_and(|metadata| { metadata.file_type().is_socket() && metadata.dev() == self.device && metadata.ino() == self.inode }) { let _ = fs::remove_file(&self.path); } } } pub(crate) fn init( event_loop: &mut EventLoop<'static, Ferese>, endpoint: Endpoint, ) -> Result> { let Endpoint { path, instance } = endpoint; prepare_parent(&path)?; let listener = bind_listener(&path)?; fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; let metadata = fs::symlink_metadata(&path)?; let guard = IpcSocketGuard { path: path.clone(), device: metadata.dev(), inode: metadata.ino(), _instance: instance, }; let (sender, receiver): (channel::SyncSender, channel::Channel) = channel::sync_channel(REQUEST_QUEUE_CAPACITY); event_loop.handle().insert_source(receiver, |event, _, state| { if let channel::Event::Msg(event) = event { let call = match event { IpcEvent::Call(call) => *call, IpcEvent::ActivityDetected(owner, matched, fd) => { let output = state .outputs_json() .as_array() .and_then(|outputs| { outputs .iter() .find(|output| output["focused"] == true) .or_else(|| outputs.first()) }) .and_then(|output| output["name"].as_str()) .map(str::to_owned); state.activities.detect_process(owner, *matched, output, &fd); return; } IpcEvent::ActivityOwnerExited(owner) => { state.activities.owner_exit(owner); return; } IpcEvent::Closed(owner) => { state.activities.remove_connection(owner); state.ipc_events.remove(owner); for request in state.screenshot.terminate_owner(owner) { state .pending_screencopies .retain(|capture| capture.request_id() != Some(request)); } let captured = state.input_capture.active(); state.input_capture.remove_owner(owner); if captured && !state.input_capture.active() { state.restore_input_capture_focus(); } state.portal_shortcuts.remove(owner); state.portal_session.remove(owner); state.theme_engine.remove(owner); state.media_engine.remove(owner); state.refresh_idle_inhibition(); return; } }; if call.request.command == "activity-publish" { let result = validate_request(&call.request) .map_err(|error| error.message) .and_then(|()| { if state.session_lock.active() { return Err("Session locked".into()); } let (owner, publish, matched) = call.activity.ok_or("Unverified activity owner")?; let output = state .outputs_json() .as_array() .and_then(|outputs| { outputs .iter() .find(|output| output["focused"] == true) .or_else(|| outputs.first()) }) .and_then(|output| output["name"].as_str()) .map(str::to_owned); let window = state.activity_window(&owner); state .activities .publish_verified(owner, publish, matched, output, window) }); state.activities.schedule(); let response = match result { Ok(result) => Response::success(call.request.id, result), Err(error) => Response::error(call.request.id, "activity_rejected", error), }; let _ = call.response.try_send(response); } else if call.request.command == "activity-get" { if state.session_lock.active() { let _ = call.response.try_send(Response::error( call.request.id, "session_locked", "IPC unavailable while session is locked", )); } else if let Err(error) = validate_request(&call.request) { let _ = call .response .try_send(Response::error(call.request.id, error.code, error.message)); } else { state.activities.get(call.request.id, call.response); } } else if call.request.command == "activity-watch" { if state.session_lock.active() { let _ = call.response.try_send(Response::error( call.request.id, "session_locked", "IPC unavailable while session is locked", )); } else if let Err(error) = validate_request(&call.request) { let _ = call .response .try_send(Response::error(call.request.id, error.code, error.message)); } else if let Some(since) = call.request.args["since"].as_u64() { state .activities .watch(call.owner, call.request.id, since, call.response); } else { let _ = call.response.try_send(Response::error( call.request.id, "invalid_argument", "Missing activity revision", )); } } else if call.request.command == "event-stream" { let result = validate_request(&call.request).and_then(|()| { if call.request.args["version"].as_u64() != Some(u64::from(ferese_ipc::events::VERSION)) { return Err(CommandError::new( "unsupported_event_version", "Unsupported event schema version", )); } state .ipc_events .subscribe(call.owner, call.events.expect("event stream sender")) .map_err(|error| CommandError::new("subscriber_limit", error)) }); let response = match result { Ok(()) => Response::success(call.request.id, json!({"version": ferese_ipc::events::VERSION})), Err(error) => Response::error(call.request.id, error.code, error.message), }; let _ = call.response.try_send(response); } else if call.request.command.starts_with("input-capture-") && !call.native_portal { let _ = call.response.try_send(Response::error( call.request.id, "access_denied", "Input capture is restricted to the native portal", )); } else if call.request.command == "input-capture-watch" { if let Err(error) = validate_request(&call.request) { let _ = call .response .try_send(Response::error(call.request.id, error.code, error.message)); } else if let Some(id) = call.request.args["session"].as_u64() { state .input_capture .watch(call.owner, call.request.id, id, call.response); } else { let _ = call.response.try_send(Response::error( call.request.id, "invalid_argument", "Missing capture session", )); } } else if matches!(call.request.command.as_str(), "media-watch" | "media-action") { if let Err(error) = validate_request(&call.request) { let _ = call .response .try_send(Response::error(call.request.id, error.code, error.message)); } else if state.session_lock.active() { let _ = call.response.try_send(Response::error( call.request.id, "session_locked", "IPC unavailable while session is locked", )); } else if call.request.command == "media-watch" { if let Some(since) = call.request.args["since"].as_u64() { state .media_engine .watch(call.owner, call.request.id, since, call.response); } else { let _ = call.response.try_send(Response::error( call.request.id, "invalid_argument", "Missing media revision", )); } } else if let Err(error) = state .media_engine .action(call.request.args, Some((call.request.id, call.response.clone()))) { let _ = call .response .try_send(Response::error(call.request.id, "media_unavailable", error)); } } else if call.request.command == "theme-watch" { if let Err(error) = validate_request(&call.request) { let _ = call .response .try_send(Response::error(call.request.id, error.code, error.message)); } else if let Some(since) = call.request.args["since"].as_u64() { state .theme_engine .watch(call.owner, call.request.id, since, call.response); } else { let _ = call.response.try_send(Response::error( call.request.id, "invalid_argument", "Missing theme revision", )); } } else if call.request.command == "session-watch" { if let Err(error) = validate_request(&call.request) { let _ = call .response .send(Response::error(call.request.id, error.code, error.message)); } else if let Some(since) = call.request.args["since"].as_u64() { state .portal_session .watch(call.owner, call.request.id, since, call.response); } else { let _ = call.response.send(Response::error( call.request.id, "invalid_argument", "Missing session revision", )); } } else if call.request.command == "reload-config" { if let Err(error) = validate_request(&call.request) { let _ = call .response .try_send(Response::error(call.request.id, error.code, error.message)); } else if state.session_lock.active() { let _ = call.response.try_send(Response::error( call.request.id, "session_locked", "IPC unavailable while session is locked", )); } else if let Err(error) = state.queue_config_reload(false, Some((call.request.id, call.response))) { tracing::warn!(%error, "cannot queue configuration reload"); } } else if call.request.command == "screenshot-window" { state.start_window_screenshot(call.owner, call.request, call.response); } else if call.request.command == "screenshot" { // Deferred: this path answers the caller itself, exactly // once, whenever the request finishes or is terminated. state.start_screenshot(call.owner, call.request, call.response); } else { let response = state.handle_ipc_request(call.owner, call.request); let _ = call.response.send(response); } } })?; // Screenshot readbacks publish parts here, so the loop wakes as soon as a // readback lands, whichever backend produced it. let (parts, part_events) = channel::channel::(); event_loop.handle().insert_source(part_events, |event, _, state| { if let channel::Event::Msg(outcome) = event { state.on_screenshot_part(outcome); } })?; let (encoded, encoded_events) = channel::sync_channel::(RESULT_QUEUE_CAPACITY); event_loop.handle().insert_source(encoded_events, |event, _, state| { if let channel::Event::Msg(result) = event { state.on_screenshot_encoded(result); } })?; let worker = Worker::spawn(encoded); let activity_timer = DeadlineTimer::new(event_loop.handle(), |state: &mut Ferese, _| state.activities.tick()); let (rules_send, rules_receive) = sync_channel(1); let monitor = activity_owner::Monitor::new(sender.clone()); let state_monitor = monitor.clone(); let state_sender = sender.clone(); event_loop.handle().insert_idle(move |state| { state.activities.timer = Some(activity_timer); state.activities.monitor = Some(state_monitor); state.activities.attach_process_source(state_sender); state.activities.schedule(); let _ = rules_send.send(state.activities.rules.clone()); }); let signal = event_loop.get_signal(); thread::Builder::new() .name("ferese-ipc-listener".to_owned()) .spawn(move || { if let Ok(rules) = rules_receive.recv() { accept_connections(listener, sender, signal, rules, monitor); } })?; // A client that dies between receiving a screenshot path and unlinking it // leaves the file behind, so staged files are reclaimed at startup and then // periodically. The TTL keeps this from disturbing a live request. screenshot_worker::sweep_stale_files(); event_loop .handle() .insert_source(timer::Timer::from_duration(SWEEP_INTERVAL), |_, _, _| { screenshot_worker::sweep_stale_files(); timer::TimeoutAction::ToDuration(SWEEP_INTERVAL) })?; // Admission and every terminal coordinator transition update this one-shot // source. No deadline source is registered when there are no requests. let deadline_timer = DeadlineTimer::new(event_loop.handle(), |state: &mut Ferese, now| { for request in state.screenshot.expire_at(now) { state .pending_screencopies .retain(|capture| capture.request_id() != Some(request)); } }); tracing::info!(path = %path.display(), "Ferese IPC is accepting connections"); let Some(worker) = worker else { return Err("Could not start the screenshot worker".into()); }; Ok(ScreenshotInit { parts, worker, deadline_timer, _guard: guard, }) } pub(crate) struct ScreenshotInit { pub(crate) parts: PartSender, pub(crate) worker: Worker, pub(crate) deadline_timer: DeadlineTimer, pub(crate) _guard: IpcSocketGuard, } // The timer callback clears its registration before expiring requests, allowing // the coordinator observer to arm the next deadline during the same dispatch. // Keeping absolute Instants also avoids pushing a timeout later on each rearm. pub(crate) struct DeadlineTimer { handle: LoopHandle<'static, Data>, armed: Rc>>, expire: fn(&mut Data, Instant), } impl DeadlineTimer { fn new(handle: LoopHandle<'static, Data>, expire: fn(&mut Data, Instant)) -> Self { Self { handle, armed: Rc::new(RefCell::new(None)), expire, } } pub(crate) fn update(&self, deadline: Option) { if self.armed.borrow().as_ref().map(|(deadline, _)| *deadline) == deadline { return; } if let Some((_, token)) = self.armed.borrow_mut().take() { self.handle.remove(token); } let Some(deadline) = deadline else { return; }; let armed = self.armed.clone(); let expire = self.expire; // calloop's Timer registers only in its in-memory wheel and always // returns Ok; a failure here would break that infrastructure invariant. let token = self .handle .insert_source(timer::Timer::from_deadline(deadline), move |_, _, state| { armed.borrow_mut().take(); expire(state, Instant::now()); timer::TimeoutAction::Drop }) .expect("could not register screenshot deadline timer"); *self.armed.borrow_mut() = Some((deadline, token)); } } impl Drop for DeadlineTimer { fn drop(&mut self) { if let Some((_, token)) = self.armed.borrow_mut().take() { self.handle.remove(token); } } } fn accept_connections( listener: UnixListener, sender: channel::SyncSender, signal: LoopSignal, rules: crate::activities::SharedRules, monitor: activity_owner::Monitor, ) { let active_connections = Arc::new(AtomicUsize::new(0)); let connection_ids = AtomicU64::new(1); loop { let (stream, _) = match listener.accept() { Ok(connection) => connection, Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, Err(error) => { tracing::error!(%error, "IPC listener stopped"); return; } }; match peer_uid(&stream) { Ok(uid) if uid == effective_uid() => {} Ok(uid) => { tracing::warn!(uid, "rejected IPC connection from a different user"); continue; } Err(error) => { tracing::warn!(%error, "could not authenticate IPC peer"); continue; } } let Some(permit) = try_acquire_connection(&active_connections) else { tracing::warn!(limit = MAX_CONNECTIONS, "rejected IPC connection at worker limit"); continue; }; let sender = sender.clone(); let owner = connection_ids.fetch_add(1, Ordering::Relaxed); let signal = signal.clone(); let monitor = monitor.clone(); let rules = rules.clone(); if let Err(error) = thread::Builder::new() .name("ferese-ipc-client".to_owned()) .spawn(move || { let _permit = permit; serve_connection_with_activities(stream, sender, signal, owner, Some((monitor, rules))); }) { tracing::warn!(%error, "could not start IPC connection worker"); } } } fn try_acquire_connection(active: &Arc) -> Option { let mut count = active.load(Ordering::Acquire); loop { if count >= MAX_CONNECTIONS { return None; } match active.compare_exchange_weak(count, count + 1, Ordering::AcqRel, Ordering::Acquire) { Ok(_) => break, Err(actual) => count = actual, } } Some(ConnectionPermit { active: active.clone() }) } #[cfg(test)] fn serve_connection(stream: UnixStream, sender: channel::SyncSender, signal: LoopSignal, owner: u64) { serve_connection_with_activities(stream, sender, signal, owner, None) } fn serve_connection_with_activities( mut stream: UnixStream, sender: channel::SyncSender, signal: LoopSignal, owner: u64, activity_host: Option<(activity_owner::Monitor, crate::activities::SharedRules)>, ) { let native_portal = crate::handlers::window_capture::is_portal(&stream); let _connection = IpcConnection { owner, sender: sender.clone(), }; let mut idle = Some(transport::FRAME_TIMEOUT); let mut owns_lease = false; loop { let mut request: Request = match transport::read_request(&stream, idle) { Ok(request) => request, Err(ferese_ipc::FrameError::Io(error)) if matches!( error.kind(), io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe ) => { return; } Err(error) => { tracing::warn!(%error, "closing invalid IPC connection"); return; } }; if request.command == "theme-preview" { let response = match validate_request(&request) { Err(error) => Response::error(request.id, error.code, error.message), Ok(()) => match crate::theme::preview(&request.args) { Ok(value) => Response::success(request.id, value), Err(error) => Response::error(request.id, "invalid_config", error), }, }; if transport::write_response(&stream, &response).is_err() { return; } continue; } let activity = if request.command == "activity-publish" { let verified = (|| -> Result<_, String> { validate_request(&request).map_err(|error| error.message)?; let wire: ferese_ipc::activities::Request = serde_json::from_value(std::mem::take(&mut request.args)).map_err(|error| error.to_string())?; if wire.version != ferese_ipc::activities::VERSION { return Err("Unsupported activity schema".into()); } let (monitor, rules) = activity_host.as_ref().ok_or("Activity host unavailable")?; let (mut identity, parents) = monitor .verify( &stream, matches!(wire.owner, ferese_ipc::activities::OwnerScope::Parent), ) .map_err(|error| error.to_string())?; let mut publish = wire.publish; let is_run = matches!(publish, ferese_ipc::activities::Publish::Run { .. }); let matched = if let ferese_ipc::activities::Publish::Hook { command, cwd, .. } | ferese_ipc::activities::Publish::Run { command, cwd, .. } = &mut publish { let rules = rules.read().map_err(|_| "Activity rules unavailable")?.clone(); let matched = if rules.enabled || is_run { rules.matcher.matches(command, cwd, &parents) } else { None }; command.clear(); cwd.clear(); matched } else { None }; if matches!( publish, ferese_ipc::activities::Publish::Start { .. } | ferese_ipc::activities::Publish::Run { .. } ) || (matches!(publish, ferese_ipc::activities::Publish::Hook { .. }) && matched.is_some()) { identity.capture_ancestors(); } Ok((identity, publish, matched)) })(); match verified { Ok(verified) => Some(verified), Err(error) => { if transport::write_response(&stream, &Response::error(request.id, "owner_rejected", error)) .is_err() { return; } continue; } } } else { None }; 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 registers_lease = matches!( request.command.as_str(), "portal-inhibit" | "portal-monitor-register" | "portal-shortcuts-register" | "input-capture-register" | "begin-session-end" ); let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window"); let call = IpcEvent::Call(Box::new(IpcCall { native_portal, activity, owner, request, response, events: event_stream_requested.then_some(events), })); let terminal_activity = matches!(&call, IpcEvent::Call(call) if matches!(call.activity.as_ref().map(|(_, publish, _)| publish), Some(ferese_ipc::activities::Publish::End { .. } | ferese_ipc::activities::Publish::Cancel { .. }))); let sent = if terminal_activity { sender.send(call).is_ok() } else { sender.try_send(call).is_ok() }; if !sent { tracing::warn!("disconnecting IPC client because the request queue is full"); return; } let response = loop { match receiver.recv_timeout(Duration::from_secs(1)) { Ok(response) => break response, Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { let mut byte = 0_u8; // SAFETY: the live stream owns this fd and byte is writable. let result = unsafe { libc::recv( std::os::fd::AsRawFd::as_raw_fd(&stream), (&mut byte as *mut u8).cast(), 1, libc::MSG_PEEK | libc::MSG_DONTWAIT, ) }; if result == 0 { return; } if result < 0 && !matches!( io::Error::last_os_error().kind(), io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted ) { return; } } } }; let exit_accepted = exit_requested && response.error.is_none(); let event_stream_accepted = event_stream_requested && response.error.is_none(); owns_lease |= registers_lease && response.error.is_none(); idle = (!owns_lease).then_some(transport::IDLE_TIMEOUT); if let Err(error) = transport::write_response(&stream, &response) { if screenshot_requested { discard_undelivered_screenshot(&response); } tracing::debug!(%error, "IPC client disconnected before receiving its response"); return; } if event_stream_accepted { let _ = stream.set_write_timeout(Some(Duration::from_secs(2))); relay_events(&mut stream, event_receiver); return; } if exit_accepted { // Acknowledge before stopping so feresectl never races process exit. signal.stop(); signal.wakeup(); return; } } } // Socket I/O stays on the bounded connection worker, never the compositor loop. fn relay_events(stream: &mut UnixStream, events: std::sync::mpsc::Receiver>) { loop { match events.recv_timeout(Duration::from_millis(250)) { Ok(event) => { if transport::write_response(stream, event.as_ref()).is_err() { return; } } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { let mut byte = 0_u8; // SAFETY: the stream owns its descriptor and byte is writable. let result = unsafe { libc::recv( stream.as_raw_fd(), (&raw mut byte).cast(), 1, libc::MSG_PEEK | libc::MSG_DONTWAIT, ) }; if result >= 0 { return; } if !matches!( io::Error::last_os_error().kind(), io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted ) { return; } } } } } fn u32_arg(args: &Value, key: &str) -> Result { args[key] .as_u64() .and_then(|value| u32::try_from(value).ok()) .ok_or_else(|| CommandError::new("invalid_argument", format!("Missing or invalid {key}"))) } fn discard_undelivered_screenshot(response: &Response) { if response.error.is_none() && let Some(path) = response.result.as_ref().and_then(|result| result["path"].as_str()) && let Err(error) = fs::remove_file(path) && error.kind() != io::ErrorKind::NotFound { tracing::debug!(%error, "failed to remove an undelivered screenshot"); } } impl Ferese { fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response { if self.session_lock.active() && !matches!( request.command.as_str(), "theme-get" | "theme-status" | "portal-shortcuts-poll" | "input-capture-disable" | "input-capture-release" | "get-session-state" | ferese_ipc::xwayland::STATUS_COMMAND | "portal-inhibit" | "portal-monitor-register" | "portal-monitor-ack" | "logind-session-ending" | "cancel-session-end" ) { return Response::error(request.id, "session_locked", "IPC unavailable while session is locked"); } if let Err(error) = validate_request(&request) { return Response::error(request.id, error.code, error.message); } match self.dispatch_ipc_command(owner, &request.command, &request.args) { Ok(result) => Response::success(request.id, result), Err(error) => Response::error(request.id, error.code, error.message), } } fn dispatch_ipc_command(&mut self, owner: u64, command: &str, args: &Value) -> Result { match command { "theme-get" | "theme-status" => return Ok(self.theme_engine.value()), "theme-set-mode" => { let mode = serde_json::from_value(args["mode"].clone()) .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; return self .set_theme_mode(mode) .map_err(|error| CommandError::new("invalid_config", error)); } "portal-inhibit" => { let inhibition = serde_json::from_value(args.clone()) .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; self.portal_session .register(owner, inhibition) .map_err(|error| CommandError::new("invalid_argument", error))?; self.refresh_idle_inhibition(); return Ok(json!({})); } "get-session-state" => { return Ok(self.portal_session.snapshot()); } ferese_ipc::xwayland::STATUS_COMMAND => { return Ok(crate::xwayland::status_snapshot(self).to_value()); } ferese_ipc::xwayland::RETRY_COMMAND => { crate::xwayland::retry(self).map_err(|error| CommandError::new("x11_unavailable", error))?; return Ok(crate::xwayland::status_snapshot(self).to_value()); } "portal-monitor-register" => { self.portal_session .register_monitor(owner) .map_err(|error| CommandError::new("invalid_argument", error))?; let mut result = self.portal_session.snapshot(); result["monitor-owner"] = json!(owner); return Ok(result); } "portal-monitor-ack" => { let token = u32_arg(args, "token")?; self.portal_session .acknowledge(owner, token) .map_err(|error| CommandError::new("invalid_argument", error))?; return Ok(json!({})); } "begin-session-end" => { self.portal_session.begin_owned_query(owner); return Ok(self.portal_session.snapshot()); } "cancel-session-end" => { self.portal_session.cancel_query(u32_arg(args, "token")?); return Ok(json!({})); } "validate-session-end" => { self.portal_session .validate_end( u32_arg(args, "token")?, u32_arg(args, "inhibitor-revision")?, args["force"] .as_bool() .ok_or_else(|| CommandError::new("invalid_argument", "Missing force confirmation"))?, ) .map_err(|error| CommandError::new("inhibited", error))?; return Ok(json!({})); } "commit-session-end" => { self.portal_session .commit_end( u32_arg(args, "token")?, u32_arg(args, "inhibitor-revision")?, args["force"] .as_bool() .ok_or_else(|| CommandError::new("invalid_argument", "Missing force confirmation"))?, ) .map_err(|error| CommandError::new("inhibited", error))?; return Ok(json!({})); } "logind-session-ending" => { let ending = args["ending"] .as_bool() .ok_or_else(|| CommandError::new("invalid_argument", "Missing shutdown state"))?; self.portal_session.set_phase(if ending { 3 } else { 1 }); return Ok(json!({})); } "portal-shortcuts-register" => { let shortcuts = serde_json::from_value(args.clone()) .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; self.portal_shortcuts .register(owner, shortcuts, &self.bindings, &self.input_settings) .map_err(|error| CommandError::new("shortcut_conflict", error))?; return Ok(json!({})); } "portal-shortcuts-poll" => { return Ok(self.portal_shortcuts.poll(owner, self.session_lock.active())); } "input-capture-register" => { if self.session_lock.active() { return Err(CommandError::new("session_locked", "Session is locked")); } let id = self .input_capture .register(owner, u32_arg(args, "capabilities")?) .map_err(|e| CommandError::new("invalid_capture", e))?; self.refresh_input_capture_zones(); let keyboard = self.seat.get_keyboard().expect("seat has keyboard"); let keymap = keyboard.with_xkb_state(self, |state| { let xkb = state.xkb().lock().unwrap(); // SAFETY: the ref-counted keymap does not escape the locked Xkb; // only its owned string is retained for the EIS keyboard. unsafe { xkb.keymap() }.get_as_string(smithay::input::keyboard::xkb::KEYMAP_FORMAT_TEXT_V1) }); return Ok(json!({"session":id, "keymap":keymap})); } "input-capture-zones" => { self.refresh_input_capture_zones(); return self .input_capture .zones( args["session"] .as_u64() .ok_or_else(|| CommandError::new("invalid_capture", "Missing session"))?, ) .map_err(|e| CommandError::new("invalid_capture", e)); } "input-capture-barriers" => { self.refresh_input_capture_zones(); let id = args["session"] .as_u64() .ok_or_else(|| CommandError::new("invalid_capture", "Missing session"))?; let barriers = serde_json::from_value(args["barriers"].clone()) .map_err(|_| CommandError::new("invalid_capture", "Invalid barriers"))?; let active = self.input_capture.active(); let failed = self .input_capture .set_barriers(id, u32_arg(args, "zone_set")?, barriers) .map_err(|e| CommandError::new("invalid_capture", e))?; if active && !self.input_capture.active() { self.restore_input_capture_focus(); } return Ok(json!({"failed_barriers":failed})); } "input-capture-enable" => { self.refresh_input_capture_zones(); let id = args["session"] .as_u64() .ok_or_else(|| CommandError::new("invalid_capture", "Missing session"))?; self.input_capture .enable(id) .map_err(|e| CommandError::new("invalid_capture", e))?; } "input-capture-disable" | "input-capture-release" => { let id = args["session"] .as_u64() .ok_or_else(|| CommandError::new("invalid_capture", "Missing session"))?; let position = args .get("cursor_position") .filter(|position| !position.is_null()) .map(|position| { let position = position .as_array() .filter(|values| values.len() == 2) .ok_or("Invalid cursor position")?; let point = ( position[0].as_f64().ok_or("Invalid cursor position")?, position[1].as_f64().ok_or("Invalid cursor position")?, ); self.input_capture .valid_position(point) .then_some(point) .ok_or("Cursor position is outside the capture zones") }) .transpose() .map_err(|e| CommandError::new("invalid_capture", e))?; let active = self.input_capture.active(); let result = if command == "input-capture-disable" { self.input_capture.disable(id) } else { self.input_capture .release(id, args["activation_id"].as_u64().and_then(|id| u32::try_from(id).ok())) }; result.map_err(|e| CommandError::new("invalid_capture", e))?; if active && !self.input_capture.active() { if let Some(position) = position && let Some(pointer) = self.seat.get_pointer() { pointer.set_location(position.into()); } self.restore_input_capture_focus(); } } "exit" => {} // The IPC worker stops the loop after writing the response. "request-logout" => self.request_logout_confirmation(), "reload-config" => self .reload_config() .map_err(|e| CommandError::new("invalid_config", e))?, "focus" => self.focus_direction(direction_arg(args)?), "focus-last-window" => self.focus_last_window(), "focus-floating" => self.focus_floating(), "focus-mru-next" => self.cycle_focus(false, false), "focus-mru-previous" => self.cycle_focus(true, false), "move" => self.move_direction(direction_arg(args)?), "resize" => self.resize_direction(direction_arg(args)?), "workspace" => self.switch_workspace(workspace_arg(args)?), "workspace-back-and-forth" => self.workspace_back_and_forth(), "move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?), "toggle-floating" => self.toggle_focused_floating(), "toggle-fullscreen" => self.toggle_focused_fullscreen(), "toggle-maximized" => self.toggle_focused_maximized(), "toggle-layout" => self.toggle_layout_mode(), "toggle-overview" => self.toggle_overview(), "toggle-display-mode" => { self.toggle_display_mode(); } "toggle-keybinding-guide" => { if !self.toggle_keybinding_guide() { return Err(CommandError::new( "unavailable", "Shortcut hint requires an unlocked session and an updated Ferese shell", )); } } "cycle-column-width" => self.cycle_focused_column_width(), "center-column" => self.center_focused_column(), "toggle-column-tabbed" => self.toggle_column_tabbed(), "join-tab" => self.join_tab(direction_arg(args)?), "leave-tab" => self.leave_tab(), "next-tab" => self.cycle_tab(false), "previous-tab" => self.cycle_tab(true), "consume" => self.consume_focused_window(), "expel" => self.expel_focused_window(), "close" => self.close_focused_window(), "get-focused-window" => return Ok(self.focused_window_json()), "get-windows" => return Ok(self.windows_json()), "media-get" => return Ok(self.media_engine.value()), "get-idle-inhibition" => { return Ok(json!({ "inhibited": self.idle_notifier_state.is_inhibited(), "automatic": self.automatic_idle_inhibited, "fullscreen-playback": self.idle_inhibit.fullscreen_playback, "portal": self.portal_session.idle_inhibited(), "players": self.media_players.iter().map(|player| json!({ "name": player.name, "desktop-entry": player.desktop_entry, "playing": player.playing, })).collect::>(), })); } "get-keybindings" => { let map = crate::config::physical_keymap(&self.input_settings).ok(); return Ok(json!( self.binding_modes .bindings(&self.bindings) .iter() .filter_map(|binding| binding.guide_entry(map.as_ref())) .take(256) .collect::>() )); } "has-client-surfaces" => { let pid = args .get("pid") .and_then(Value::as_u64) .and_then(|pid| u32::try_from(pid).ok()) .ok_or_else(|| CommandError::new("invalid_argument", "pid must be an unsigned process ID"))?; return Ok(json!(self.has_client_surfaces(pid))); } "get-workspaces" => return Ok(self.workspaces_json()), "outputs" | "get-outputs" => return Ok(self.outputs_json()), "output-profiles" => { return Ok(json!({ "confirmation_pending": self.direct_backend.as_ref().is_some_and(|backend| backend.confirmation_pending()), "manual_profile": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.profile.as_ref()), "manual_layout": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.layout), "manual_internal": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.internal), "profiles": self.output_profiles.iter().map(|profile| json!({ "name": profile.name, "layout": profile.layout, "confirm_timeout": profile.confirm_timeout, "lid_policy": profile.lid_policy, "lid_closed": profile.lid_closed, "mirror_source": profile.mirror_source, "outputs": profile.outputs.iter().map(|output| json!({"selector": output.matcher, "required": output.required, "enabled": output.enabled})).collect::>() })).collect::>() })); } "output-confirm" | "output-revert" => { crate::backends::direct::confirm_output_configuration(self, command == "output-confirm") .map_err(|error| CommandError::new("output_configuration_failed", error))?; return Ok(self.outputs_json()); } "output-profile" => { let name = args["name"] .as_str() .ok_or_else(|| CommandError::new("invalid_arguments", "output-profile requires a name or auto"))?; crate::backends::direct::set_output_profile(self, name) .map_err(|error| CommandError::new("output_configuration_failed", error))?; return Ok(self.outputs_json()); } "output-layout" => { let layout = serde_json::from_value::(args["layout"].clone()) .map_err(|_| CommandError::new("invalid_arguments", "unknown display layout"))?; crate::backends::direct::set_output_layout(self, layout) .map_err(|error| CommandError::new("output_configuration_failed", error))?; return Ok(self.outputs_json()); } "output-internal" => { let enabled = args["enabled"] .as_bool() .ok_or_else(|| CommandError::new("invalid_arguments", "output-internal requires on or off"))?; crate::backends::direct::set_internal_output(self, enabled) .map_err(|error| CommandError::new("output_configuration_failed", error))?; return Ok(self.outputs_json()); } _ => { return Err(CommandError::new( "unknown_command", format!("unknown command {command:?}"), )); } } Ok(json!({})) } // Screenshot replies are deferred: this either answers the caller now, or // hands the reply to the coordinator, which answers exactly once. pub(crate) fn start_screenshot(&mut self, owner: u64, request: Request, response: SyncSender) { // A macro rather than a closure: the message may be a borrowed str or an // owned String, and Response::error already accepts either. macro_rules! reject { ($code:expr, $message:expr) => { let _ = response.try_send(Response::error(request.id, $code, $message)); }; } if self.session_lock.active() { reject!("session_locked", "IPC unavailable while session is locked"); return; } if !screencopy::capture_allowed() { reject!( "capture_disabled", "Screen capture is disabled for this session; start it with \ FERESE_ENABLE_SCREENCOPY=1 to enable screenshots" ); return; } if let Err(error) = validate_request(&request) { reject!(error.code, error.message); return; } let geometry = match geometry_arg(&request.args) { Ok(geometry) => geometry, Err(message) => { reject!("invalid_argument", &message); return; } }; if self.screenshot_parts.is_none() || self.screenshot_worker.is_none() { reject!("unavailable", "Screenshot capture is not available"); return; } let Some(parts) = self.screenshot_parts.clone() else { reject!("unavailable", "Screenshot capture is not available"); return; }; // Snapshot the layout, so a later move, rescale, or transform cannot // change what this request captures. let targets: Vec<(Output, OutputLayout)> = self .space .outputs() .filter_map(|output| { let mode = output.current_mode()?; let geometry = self.space.output_geometry(output)?; Some(( output.clone(), OutputLayout { mode_size: mode.size, scale: output.current_scale().fractional_scale(), transform: output.current_transform(), location: geometry.loc, }, )) }) .collect(); let layouts: Vec = targets.iter().map(|(_, layout)| layout.clone()).collect(); let planned = match plan(&geometry, &layouts) { Ok(planned) => planned, Err(message) => { reject!("invalid_request", &message); return; } }; let specs = planned.iter().map(|part| part.spec(&layouts[part.index])).collect(); // admit stores the reply only on success, so a clone survives the // rejection path and every caller is answered exactly once. let fallback = response.clone(); let id = match self.screenshot.admit(owner, request.id, response, specs) { Ok(id) => id, Err(message) => { let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", message)); return; } }; for (position, part) in planned.iter().enumerate() { let (output, _) = &targets[part.index]; self.pending_screencopies.push( crate::handlers::screencopy::PendingScreencopy::owned( id, position, parts.clone(), output.clone(), part.buffer, ) .with_permit(self.screenshot.permit(id)), ); } // The nested backend redraws on its refresh timer; this drives the // direct backend immediately and is a no-op otherwise. let outputs = planned .iter() .map(|part| targets[part.index].0.clone()) .collect::>(); crate::backends::direct::render_on(self, &outputs); } fn start_window_screenshot(&mut self, owner: u64, request: Request, response: SyncSender) { let reject = |message: String| { let _ = response.try_send(Response::error(request.id, "window_capture_failed", message)); }; if self.session_lock.active() || !screencopy::capture_allowed() { reject("Screen capture is unavailable".into()); return; } if let Err(error) = validate_request(&request) { reject(error.message); return; } let Some(id) = request .args .get("window") .and_then(Value::as_u64) .map(ferese_layout::WindowId) else { reject("Expected a window ID".into()); return; }; let Some(window) = self .windows .ids() .iter() .find(|(_, candidate)| **candidate == id) .map(|(window, _)| window.clone()) else { reject("Window no longer exists".into()); return; }; if self.capture_protected(&window) { reject("Window is protected from capture".into()); return; } let Some(output) = self .space .outputs() .find(|output| self.window_belongs_to_output(id, output)) .cloned() else { reject("Window output is unavailable".into()); return; }; let geometry = window.geometry(); let scale = output.current_scale().fractional_scale(); let size = geometry.size.to_physical_precise_round(scale); let spec = crate::handlers::screenshot::PartSpec { preserve_alpha: true, transform: Transform::Normal, scale, location: (0, 0), logical_width: geometry.size.w, logical_height: geometry.size.h, buffer_width: size.w, buffer_height: size.h, }; let fallback = response.clone(); let capture = match self.screenshot.admit(owner, request.id, response, vec![spec]) { Ok(capture) => capture, Err(error) => { let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", error)); return; } }; let result = if let Some(backend) = &self.nested_backend { match backend.try_borrow_mut() { Ok(mut backend) => crate::render::capture_window_buffer(backend.renderer(), &window, geometry, scale), Err(_) => Err("Window renderer is busy".into()), } } else if let Some(backend) = &mut self.direct_backend { backend.capture_window_buffer(&window, geometry, &output) } else { Err("Window renderer is unavailable".into()) }; self.on_screenshot_part(PartOutcome { request: capture, part: 0, result, permit: self.screenshot.permit(capture), }); } pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) { let PartOutcome { request, part, result, permit, } = outcome; let action = self.screenshot.on_part(request, part, result); // Pixels have moved into the coordinator/job before the channel lease is dropped. drop(permit); self.apply_screenshot_action(action); } pub(crate) fn on_screenshot_encoded(&mut self, result: Encoded) { let action = self.screenshot.on_encoded(result.request, result.result); self.apply_screenshot_action(action); } fn apply_screenshot_action(&mut self, action: Action) { match action { Action::None => {} Action::Encode { request, frames, permit, } => { let submitted = self.screenshot_worker.as_ref().is_some_and(|worker| { worker .submit(Job { request, frames, _permit: Some(permit), }) .is_ok() }); if !submitted { self.screenshot.reject(request, "Screenshot encoding queue is full"); } } Action::Delivered(path) => { // The caller opens and unlinks it; the path was already sent. tracing::debug!(path = %path.display(), "delivered a screenshot"); } Action::DiscardFile(path) => { if let Err(error) = fs::remove_file(&path) && error.kind() != io::ErrorKind::NotFound { tracing::debug!( %error, path = %path.display(), "failed to remove a staged screenshot" ); } } } } pub(crate) fn ipc_event_snapshot(&self) -> ferese_ipc::events::Snapshot { let locked = self.session_lock.active(); let mut outputs = self.outputs_json(); if locked { for output in outputs.as_array_mut().into_iter().flatten() { if let Some(fields) = output.as_object_mut() { fields.remove("workspace"); fields.remove("focused"); } } } let windows = if locked { json!([]) } else { Value::Array(self.windows.ordered_ids().filter_map(|id| { let record = self.windows.record(id)?; let workspace = self.workspaces.workspace_for_window(id)?; Some(json!({ "id": id.0, "app_id": record.app_id, "title": record.title, "focused": self.focused_window == Some(id), "workspace": workspace.0, "floating": matches!(self.workspaces.placement(id), Some(ferese_core::WindowPlacement::Floating { .. })), "fullscreen": self.workspaces.workspace(workspace).is_some_and(|workspace| workspace.fullscreen == Some(id)), })) }).collect()) }; let live = &self.theme_engine.live; let theme = json!({ "mode": live.mode, "theme": live.theme, "families": live.families, "warnings": live.warnings, "error": live.error, "fallback_note": live.fallback_note, }); ferese_ipc::events::Snapshot { outputs, workspaces: if locked { json!([]) } else { self.workspaces_json() }, windows, focus: if locked { Value::Null } else { json!({ "window": self.focused_window.map(|id| id.0), "output": self.output_workspaces.focused_output().map(|id| id.0), "workspace": self.output_workspaces.focused_output().and_then(|id| self.output_workspaces.active_workspace(id)).map(|id| id.0), }) }, config: ferese_ipc::events::ConfigState { revision: self.ipc_events.config_revision, error: self.theme_engine.live.error.clone(), }, theme, lock: self.session_lock.event_state(), } } fn focused_window_json(&self) -> Value { self.focused_window .map(|window| json!({ "id": window.0 })) .unwrap_or(Value::Null) } fn has_client_surfaces(&self, pid: u32) -> bool { use smithay::reexports::wayland_server::Resource; let belongs_to = |surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface| { surface .client() .and_then(|client| client.get_credentials(&self.display_handle).ok()) .is_some_and(|credentials| credentials.pid as u32 == pid) }; self.windows .ids() .keys() .filter_map(|window| window.toplevel()) .any(|toplevel| belongs_to(toplevel.wl_surface())) || self.space.outputs().any(|output| { smithay::desktop::layer_map_for_output(output) .layers() .any(|layer| belongs_to(layer.wl_surface())) }) } fn windows_json(&self) -> Value { use smithay::wayland::compositor::with_states; use smithay::wayland::shell::xdg::XdgToplevelSurfaceData; let mut windows = self .windows .ids() .iter() .filter_map(|(window, id)| { let toplevel = window.toplevel()?; let (app_id, title) = with_states(toplevel.wl_surface(), |states| { let attributes = states.data_map.get::().unwrap().lock().unwrap(); ( attributes.app_id.clone().unwrap_or_default(), attributes.title.clone().unwrap_or_default(), ) }); let rect = self.visual_rect_for_window(window); Some(json!({ "id": id.0, "app_id": app_id, "title": title, "focused": self.focused_window == Some(*id), "mapped": self.space.element_location(window).is_some(), "workspace": self.workspaces.workspace_for_window(*id).map(|workspace| workspace.0), "x": rect.as_ref().map(|rect| rect.loc.x), "y": rect.as_ref().map(|rect| rect.loc.y), "capture_width": window.geometry().size.w, "capture_height": window.geometry().size.h, "width": rect.as_ref().map(|rect| rect.size.w), "height": rect.as_ref().map(|rect| rect.size.h), })) }) .collect::>(); windows.sort_by_key(|window| window["id"].as_u64()); Value::Array(windows) } fn workspaces_json(&self) -> Value { let workspaces = self .output_workspaces .workspace_views(&self.workspaces) .into_iter() .map(|view| { let workspace = self.workspaces.workspace(view.id).expect("workspace view exists"); let mode = match workspace.layout.mode() { LayoutMode::Scrolling => "scrolling", LayoutMode::Tree => "tree", }; json!({ "id": workspace.id.0, "name": view.index.to_string(), "index": view.index, "output": view.output.0, "window_count": view.window_count, "visible": view.visible, "focused": view.focused, "active": view.focused, "layout": mode, "focused_window": workspace.last_focused.map(|window| window.0), "fullscreen_window": workspace.fullscreen.map(|window| window.0), }) }) .collect::>(); Value::Array(workspaces) } fn outputs_json(&self) -> Value { if let Some(backend) = self.direct_backend.as_ref() { let focused = self.output_workspaces.focused_output(); let mut outputs = backend .connected_outputs .iter() .map(|info| { let mapped = self.output_by_identity(&info.identity); let id = self.persistent_output_id(&info.identity); let geometry = mapped.and_then(|output| self.space.output_geometry(output)); let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id)); let position = geometry .map(|geometry| [geometry.loc.x, geometry.loc.y]) .or(info.configured_position); let scale = mapped .map(|output| output.current_scale().fractional_scale()) .unwrap_or(info.scale); let transform = mapped .map(|output| transform_name(output.current_transform())) .unwrap_or_else(|| configured_transform_name(info.transform)); let modes = info .available_modes .iter() .map(|mode| { json!({ "width": mode.width, "height": mode.height, "refresh_millihertz": mode.refresh_millihertz, "refresh_hz": f64::from(mode.refresh_millihertz) / 1_000.0, "preferred": mode.preferred, }) }) .collect::>(); let current_mode = info.current_mode.map(|mode| { json!({ "width": mode.width, "height": mode.height, "refresh_millihertz": mode.refresh_millihertz, "refresh_hz": f64::from(mode.refresh_millihertz) / 1_000.0, }) }); json!({ "id": id.map(|id| id.0), "name": info.connector, "connector": info.connector, "identity": info.identity, "connected": info.connected, "internal": info.internal, "requested_enabled": info.requested_enabled, "applied_enabled": info.enabled, "mirror_source": info.mirror_source, "enabled": info.enabled, "profile": info.profile, "requested_profile": info.requested_profile, "configuration_error": backend.output_configuration_error(), "confirmation_pending": backend.confirmation_pending(), "auto_refresh": info.auto_refresh, "low_power": backend.low_power, "focused": id.is_some() && id == focused, "workspace": workspace.map(|workspace| workspace.0), "x": position.map(|position| position[0]), "y": position.map(|position| position[1]), "width": geometry.map(|geometry| geometry.size.w), "height": geometry.map(|geometry| geometry.size.h), "physical_width_mm": info.physical_size.map(|size| size.0), "physical_height_mm": info.physical_size.map(|size| size.1), "scale": scale, "transform": transform, "current_mode": current_mode, "available_modes": modes, }) }) .collect::>(); outputs.sort_by(|left, right| left["connector"].as_str().cmp(&right["connector"].as_str())); return Value::Array(outputs); } let focused = self.output_workspaces.focused_output(); let mut outputs = self .space .outputs() .filter_map(|output| { let id = self.output_id(output)?; let geometry = self.space.output_geometry(output)?; let workspace = self.output_workspaces.active_workspace(id)?; let mode = output.current_mode(); let physical = output.physical_properties().size; Some(json!({ "id": id.0, "name": output.name(), "connector": output.name(), "identity": Value::Null, "connected": true, "enabled": true, "focused": Some(id) == focused, "workspace": workspace.0, "x": geometry.loc.x, "y": geometry.loc.y, "width": geometry.size.w, "height": geometry.size.h, "physical_width_mm": physical.w, "physical_height_mm": physical.h, "scale": output.current_scale().fractional_scale(), "transform": transform_name(output.current_transform()), "current_mode": mode.map(|mode| json!({ "width": mode.size.w, "height": mode.size.h, "refresh_millihertz": mode.refresh, "refresh_hz": f64::from(mode.refresh) / 1_000.0, })), "available_modes": output.modes().into_iter().map(|mode| json!({ "width": mode.size.w, "height": mode.size.h, "refresh_millihertz": mode.refresh, "refresh_hz": f64::from(mode.refresh) / 1_000.0, "preferred": Some(mode) == output.preferred_mode(), })).collect::>(), })) }) .collect::>(); outputs.sort_by_key(|output| output["id"].as_u64()); Value::Array(outputs) } } fn geometry_arg(args: &Value) -> Result { match args.get("geometry") { None | Some(Value::Null) => Ok(Geometry::All), Some(value) => { let text = value.as_str().ok_or("geometry must be a string like \"x,y WxH\"")?; parse_geometry(text) } } } fn validate_request(request: &Request) -> Result<(), CommandError> { if request.version != VERSION { return Err(CommandError::new( "unsupported_version", format!("supported version is {VERSION}"), )); } if request.kind != "command" { return Err(CommandError::new("invalid_type", "expected type \"command\"")); } Ok(()) } fn transform_name(transform: Transform) -> &'static str { match transform { Transform::Normal => "normal", Transform::_90 => "rotate_90", Transform::_180 => "rotate_180", Transform::_270 => "rotate_270", Transform::Flipped => "flipped", Transform::Flipped90 => "flipped_90", Transform::Flipped180 => "flipped_180", Transform::Flipped270 => "flipped_270", } } fn configured_transform_name(transform: OutputTransform) -> &'static str { match transform { OutputTransform::Normal => "normal", OutputTransform::Rotate90 => "rotate_90", OutputTransform::Rotate180 => "rotate_180", OutputTransform::Rotate270 => "rotate_270", OutputTransform::Flipped => "flipped", OutputTransform::Flipped90 => "flipped_90", OutputTransform::Flipped180 => "flipped_180", OutputTransform::Flipped270 => "flipped_270", } } #[derive(Debug)] struct CommandError { code: &'static str, message: String, } impl CommandError { fn new(code: &'static str, message: impl Into) -> Self { Self { code, message: message.into(), } } } fn direction_arg(args: &Value) -> Result { match args.get("direction").and_then(Value::as_str) { Some("left") => Ok(Direction::Left), Some("right") => Ok(Direction::Right), Some("up") => Ok(Direction::Up), Some("down") => Ok(Direction::Down), _ => Err(CommandError::new( "invalid_argument", "direction must be left, right, up, or down", )), } } fn workspace_arg(args: &Value) -> Result { args.get("index") .and_then(Value::as_u64) .and_then(|index| u32::try_from(index).ok()) .filter(|index| *index > 0) .ok_or_else(|| CommandError::new("invalid_argument", "index must be a positive 32-bit integer")) } fn prepare_parent(socket: &Path) -> Result<(), io::Error> { let parent = socket.parent().expect("the Ferese control socket always has a parent"); prepare_directory(parent) } fn prepare_directory(parent: &Path) -> Result<(), io::Error> { match fs::create_dir(parent) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error), } match fs::symlink_metadata(parent) { Ok(metadata) => { if !metadata.is_dir() || metadata.file_type().is_symlink() { return Err(io::Error::new( io::ErrorKind::InvalidData, format!("{} is not a real directory", parent.display()), )); } if metadata.uid() != effective_uid() { return Err(io::Error::new( io::ErrorKind::PermissionDenied, format!("{} is owned by another user", parent.display()), )); } } Err(error) => return Err(error), } fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) } fn bind_listener(path: &Path) -> Result { match UnixListener::bind(path) { Ok(listener) => Ok(listener), Err(error) if error.kind() == io::ErrorKind::AddrInUse => { if UnixStream::connect(path).is_ok() { return Err(io::Error::new( io::ErrorKind::AddrInUse, "another Ferese IPC server is already running", )); } let metadata = fs::symlink_metadata(path)?; if !metadata.file_type().is_socket() || metadata.uid() != effective_uid() { return Err(error); } fs::remove_file(path)?; UnixListener::bind(path) } Err(error) => Err(error), } } fn peer_uid(stream: &UnixStream) -> Result { let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 }; let mut length = std::mem::size_of::() as libc::socklen_t; // SAFETY: `credentials` and `length` point to initialized, correctly sized // storage for Linux SO_PEERCRED, and the stream owns a live file descriptor. let result = unsafe { libc::getsockopt( stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, (&raw mut credentials).cast(), &raw mut length, ) }; if result == -1 { Err(io::Error::last_os_error()) } else { Ok(credentials.uid) } } fn effective_uid() -> u32 { // SAFETY: `geteuid` has no preconditions and cannot fail. unsafe { libc::geteuid() } } #[cfg(test)] mod tests { use std::env; use std::io::{Read, Write}; use std::net::Shutdown; use std::os::unix::fs::PermissionsExt; use super::*; #[derive(Default)] struct DeadlineState { fired: usize, next: Option, timer: Option>, } fn deadline_loop() -> (EventLoop<'static, DeadlineState>, DeadlineState) { let event_loop = EventLoop::try_new().unwrap(); let timer = DeadlineTimer::new(event_loop.handle(), |state: &mut DeadlineState, _| { state.fired += 1; state.timer.as_ref().unwrap().update(state.next.take()); }); ( event_loop, DeadlineState { timer: Some(timer), ..Default::default() }, ) } #[test] fn screenshot_deadline_idle_has_no_timer_and_admission_arms_immediately() { let (mut event_loop, mut state) = deadline_loop(); let timer = state.timer.as_ref().unwrap(); timer.update(None); assert!(timer.armed.borrow().is_none(), "idle registers no timeout source"); event_loop.dispatch(Duration::ZERO, &mut state).unwrap(); assert_eq!(state.fired, 0); let deadline = Instant::now() + Duration::from_secs(3600); let timer = state.timer.as_ref().unwrap(); timer.update(Some(deadline)); assert_eq!( timer.armed.borrow().as_ref().map(|(deadline, _)| *deadline), Some(deadline) ); timer.update(None); assert!(timer.armed.borrow().is_none()); event_loop.dispatch(Duration::ZERO, &mut state).unwrap(); assert_eq!(state.fired, 0); } #[test] fn screenshot_deadline_replacement_and_cancellation_remove_old_sources() { let (mut event_loop, mut state) = deadline_loop(); let due = Instant::now() - Duration::from_secs(1); let future = Instant::now() + Duration::from_secs(3600); let timer = state.timer.as_ref().unwrap(); timer.update(Some(due)); let original = timer.armed.borrow().as_ref().unwrap().1; timer.update(Some(due)); assert_eq!( timer.armed.borrow().as_ref().unwrap().1, original, "unchanged deadline keeps its source" ); timer.update(Some(future)); event_loop.dispatch(Duration::ZERO, &mut state).unwrap(); assert_eq!(state.fired, 0, "the replaced due timer must never fire"); state.timer.as_ref().unwrap().update(Some(due)); state.timer.as_ref().unwrap().update(None); event_loop.dispatch(Duration::ZERO, &mut state).unwrap(); assert_eq!(state.fired, 0, "cancelling the due timer removes its wakeup"); assert!(state.timer.as_ref().unwrap().armed.borrow().is_none()); } #[test] fn screenshot_deadline_drop_removes_a_registered_due_source() { let (mut event_loop, mut state) = deadline_loop(); state .timer .as_ref() .unwrap() .update(Some(Instant::now() - Duration::from_secs(1))); drop(state.timer.take()); event_loop.dispatch(Duration::ZERO, &mut state).unwrap(); assert_eq!(state.fired, 0, "dropping the observer removes its timer callback"); } #[test] fn screenshot_deadline_expiry_can_rearm_during_dispatch_then_returns_to_idle() { let (mut event_loop, mut state) = deadline_loop(); let due = Instant::now() - Duration::from_secs(1); let future = Instant::now() + Duration::from_secs(3600); state.next = Some(future); state.timer.as_ref().unwrap().update(Some(due)); event_loop.dispatch(Duration::ZERO, &mut state).unwrap(); assert_eq!(state.fired, 1); assert_eq!( state .timer .as_ref() .unwrap() .armed .borrow() .as_ref() .map(|(deadline, _)| *deadline), Some(future) ); state.timer.as_ref().unwrap().update(Some(due)); event_loop.dispatch(Duration::ZERO, &mut state).unwrap(); assert_eq!(state.fired, 2); assert!(state.timer.as_ref().unwrap().armed.borrow().is_none()); event_loop.dispatch(Duration::ZERO, &mut state).unwrap(); assert_eq!(state.fired, 2, "an empty queue has no repeat wakeup"); } fn request(version: u32, kind: &str) -> Request { Request { version, id: 7, kind: kind.to_owned(), command: "get-outputs".to_owned(), args: json!({}), } } #[test] fn parses_direction_arguments() { assert_eq!(direction_arg(&json!({ "direction": "left" })).unwrap(), Direction::Left); assert!(direction_arg(&json!({ "direction": "diagonal" })).is_err()); } #[test] fn validates_workspace_arguments() { assert_eq!(workspace_arg(&json!({ "index": 9 })).unwrap(), 9); assert!(workspace_arg(&json!({ "index": 0 })).is_err()); assert!(workspace_arg(&json!({ "index": -1 })).is_err()); } #[test] fn rejects_unsupported_versions_and_request_types_with_stable_codes() { let version = validate_request(&request(VERSION + 1, "command")).unwrap_err(); let kind = validate_request(&request(VERSION, "event")).unwrap_err(); assert_eq!(version.code, "unsupported_version"); assert_eq!(kind.code, "invalid_type"); assert!(validate_request(&request(VERSION, "command")).is_ok()); } #[test] fn reads_same_user_peer_credentials() { let (left, right) = UnixStream::pair().unwrap(); assert_eq!(peer_uid(&left).unwrap(), effective_uid()); assert_eq!(peer_uid(&right).unwrap(), effective_uid()); } #[test] fn silent_clients_release_all_connection_slots_after_the_initial_deadline() { let active = Arc::new(AtomicUsize::new(0)); let (sender, _receiver) = channel::sync_channel(MAX_CONNECTIONS); let event_loop = EventLoop::<()>::try_new().unwrap(); let mut clients = Vec::new(); let mut workers = Vec::new(); for owner in 0..MAX_CONNECTIONS { let (client, server) = UnixStream::pair().unwrap(); clients.push(client); let permit = try_acquire_connection(&active).unwrap(); let sender = sender.clone(); let signal = event_loop.get_signal(); workers.push(thread::spawn(move || { let _permit = permit; serve_connection(server, sender, signal, owner as u64); })); } assert!(try_acquire_connection(&active).is_none()); let deadline = Instant::now() + transport::FRAME_TIMEOUT + Duration::from_secs(3); while active.load(Ordering::Acquire) != 0 { assert!(Instant::now() < deadline, "silent peers retained connection slots"); thread::sleep(Duration::from_millis(20)); } assert!(try_acquire_connection(&active).is_some()); // Clients deliberately stay open: the server must recover on its own. assert_eq!(clients.len(), MAX_CONNECTIONS); for worker in workers { worker.join().unwrap(); } } #[test] fn malformed_ipc_connection_is_closed_without_reaching_the_request_queue() { let (mut client, server) = UnixStream::pair().unwrap(); let (sender, _receiver) = channel::sync_channel(1); let event_loop = EventLoop::<()>::try_new().unwrap(); let signal = event_loop.get_signal(); let worker = thread::spawn(move || serve_connection(server, sender, signal, 1)); let invalid = b"not-json"; client.write_all(&(invalid.len() as u32).to_be_bytes()).unwrap(); client.write_all(invalid).unwrap(); client.shutdown(Shutdown::Write).unwrap(); client .set_read_timeout(Some(std::time::Duration::from_secs(1))) .unwrap(); let mut byte = [0_u8; 1]; assert_eq!(client.read(&mut byte).unwrap(), 0); worker.join().unwrap(); } #[test] fn failed_screenshot_delivery_removes_only_its_staging_file() { let root = unique_test_directory("undelivered-screenshot"); fs::create_dir(&root).unwrap(); let path = root.join("capture.png"); fs::write(&path, b"capture").unwrap(); let response = Response::success(1, serde_json::json!({"path": path})); discard_undelivered_screenshot(&response); assert!(!path.exists()); discard_undelivered_screenshot(&response); fs::remove_dir(root).unwrap(); } #[test] fn runtime_socket_directory_is_private() { let root = unique_test_directory("private-parent"); let parent = root.join("ferese"); let socket = parent.join("control.sock"); fs::create_dir(&root).unwrap(); prepare_parent(&socket).unwrap(); let metadata = fs::metadata(&parent).unwrap(); assert_eq!(metadata.permissions().mode() & 0o777, 0o700); fs::remove_dir_all(root).unwrap(); } #[test] fn nested_endpoints_reserve_private_unique_directories_and_preserve_the_default() { let runtime = tempfile::tempdir().unwrap(); let default = runtime.path().join("ferese/control.sock"); let drm = Endpoint::desktop_at(default.clone()).unwrap(); let host = bind_listener(&drm.path).unwrap(); let first = Endpoint::instance_at(default.clone()).unwrap(); let second = Endpoint::instance_at(default.clone()).unwrap(); assert_eq!(drm.path, default); assert_ne!(first.path, second.path); assert_eq!( first.path.parent().unwrap().parent().unwrap(), default.parent().unwrap().join("instances") ); for endpoint in [&first, &second] { assert_eq!( fs::metadata(endpoint.path.parent().unwrap()) .unwrap() .permissions() .mode() & 0o777, 0o700 ); } let first_directory = first.path.parent().unwrap().to_owned(); drop(first); assert!(!first_directory.exists()); assert!(second.path.parent().unwrap().is_dir()); assert!(default.symlink_metadata().unwrap().file_type().is_socket()); assert_eq!(bind_listener(&default).unwrap_err().kind(), io::ErrorKind::AddrInUse); drop(host); } #[test] fn nested_endpoint_permissions_do_not_depend_on_session_umask() { for mask in [0o000, 0o022, 0o077] { // Set the mask after exec so the parent and its parallel tests // retain their umask and socket ownership. let output = std::process::Command::new("sh") .arg("-c") .arg(format!("umask {mask:03o}\nexec \"$@\"")) .arg("ferese-ipc-umask-test") .arg(std::env::current_exe().unwrap()) .args([ "--exact", "ipc::tests::nested_endpoints_reserve_private_unique_directories_and_preserve_the_default", "--nocapture", ]) .output() .unwrap(); assert!( output.status.success(), "umask {mask:03o}:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } } #[test] fn nested_endpoint_rejects_an_instances_symlink() { let runtime = tempfile::tempdir().unwrap(); let default = runtime.path().join("ferese/control.sock"); let _drm = Endpoint::desktop_at(default.clone()).unwrap(); let outside = runtime.path().join("outside"); fs::create_dir(&outside).unwrap(); fs::set_permissions(&outside, fs::Permissions::from_mode(0o755)).unwrap(); std::os::unix::fs::symlink(&outside, default.parent().unwrap().join("instances")).unwrap(); assert!(Endpoint::instance_at(default).is_err()); assert_eq!(fs::metadata(outside).unwrap().permissions().mode() & 0o777, 0o755); } #[test] fn listener_reclaims_only_a_stale_owned_socket() { // A concurrent test can fork while this listener is open, retaining // its descriptor until exec. Keep the close/rebind check in a process // that does not spawn other children during the assertion. const CHILD: &str = "FERESE_STALE_SOCKET_TEST_CHILD"; if std::env::var_os(CHILD).is_none() { let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ "--exact", "ipc::tests::listener_reclaims_only_a_stale_owned_socket", "--nocapture", ]) .env(CHILD, "1") .output() .unwrap(); assert!( output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); return; } let root = unique_test_directory("stale-socket"); fs::create_dir(&root).unwrap(); let socket = root.join("control.sock"); let first = bind_listener(&socket).unwrap(); assert_eq!(bind_listener(&socket).unwrap_err().kind(), io::ErrorKind::AddrInUse); drop(first); let replacement = bind_listener(&socket).unwrap(); drop(replacement); fs::remove_file(socket).unwrap(); fs::remove_dir(root).unwrap(); } #[test] fn listener_never_replaces_a_non_socket_path() { let root = unique_test_directory("non-socket"); fs::create_dir(&root).unwrap(); let socket = root.join("control.sock"); fs::write(&socket, b"keep").unwrap(); assert_eq!(bind_listener(&socket).unwrap_err().kind(), io::ErrorKind::AddrInUse); assert_eq!(fs::read(&socket).unwrap(), b"keep"); fs::remove_dir_all(root).unwrap(); } #[test] fn ipc_worker_count_is_bounded_and_permits_are_reusable() { let active = Arc::new(AtomicUsize::new(0)); let mut permits = (0..MAX_CONNECTIONS) .map(|_| try_acquire_connection(&active).unwrap()) .collect::>(); assert!(try_acquire_connection(&active).is_none()); permits.pop(); assert_eq!(active.load(Ordering::Acquire), MAX_CONNECTIONS - 1); assert!(try_acquire_connection(&active).is_some()); } #[test] fn idle_event_stream_disconnect_is_detected_without_an_event() { let (client, mut server) = UnixStream::pair().unwrap(); let (_sender, receiver) = sync_channel(1); let (finished, completion) = sync_channel(1); let worker = thread::spawn(move || { relay_events(&mut server, receiver); finished.send(()).unwrap(); }); drop(client); completion.recv_timeout(Duration::from_secs(2)).unwrap(); worker.join().unwrap(); } #[test] fn stalled_event_socket_write_expires_on_the_worker() { let (_client, mut server) = UnixStream::pair().unwrap(); server.set_write_timeout(Some(Duration::from_millis(50))).unwrap(); let (sender, receiver) = sync_channel(1); let (finished, completion) = sync_channel(1); sender .send(Arc::new(ferese_ipc::events::Event { version: ferese_ipc::events::VERSION, generation: 1, last: true, change: ferese_ipc::events::Change::WindowsChanged { windows: json!([{"title": "x".repeat(900_000)}]), }, })) .unwrap(); let worker = thread::spawn(move || { relay_events(&mut server, receiver); finished.send(()).unwrap(); }); completion.recv_timeout(Duration::from_secs(2)).unwrap(); worker.join().unwrap(); assert!( sender .try_send(Arc::new(ferese_ipc::events::Event { version: ferese_ipc::events::VERSION, generation: 2, last: true, change: ferese_ipc::events::Change::FocusChanged { focus: json!(null) }, })) .is_err() ); } fn unique_test_directory(label: &str) -> PathBuf { let nonce = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); env::temp_dir().join(format!("ferese-ipc-{label}-{}-{nonce}", std::process::id())) } }