1534 lines
63 KiB
Rust
1534 lines
63 KiB
Rust
use std::os::fd::AsRawFd;
|
|
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
|
use std::os::unix::net::{UnixListener, UnixStream};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
|
use std::sync::mpsc::{SyncSender, sync_channel};
|
|
use std::time::Duration;
|
|
use std::{env, fs, io, thread};
|
|
|
|
use ferese_core::LayoutMode;
|
|
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
|
use ferese_layout::Direction;
|
|
use serde_json::{Value, json};
|
|
use smithay::output::Output;
|
|
use smithay::reexports::calloop::{EventLoop, LoopSignal, 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;
|
|
// Only consulted when no request is outstanding, so it bounds how late a newly
|
|
// admitted request can be noticed. With a request pending the timer re-arms to
|
|
// the exact remaining time instead.
|
|
const DEADLINE_IDLE: Duration = Duration::from_secs(1);
|
|
|
|
#[derive(Debug)]
|
|
struct IpcCall {
|
|
native_portal: bool,
|
|
owner: u64,
|
|
request: Request,
|
|
response: SyncSender<Response>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum IpcEvent {
|
|
Call(IpcCall),
|
|
Closed(u64),
|
|
}
|
|
|
|
struct IpcConnection {
|
|
owner: u64,
|
|
sender: channel::SyncSender<IpcEvent>,
|
|
}
|
|
|
|
impl Drop for IpcConnection {
|
|
fn drop(&mut self) {
|
|
let _ = self.sender.send(IpcEvent::Closed(self.owner));
|
|
}
|
|
}
|
|
|
|
struct ConnectionPermit {
|
|
active: Arc<AtomicUsize>,
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
impl Drop for IpcSocketGuard {
|
|
fn drop(&mut self) {
|
|
if self.path.symlink_metadata().is_ok_and(|metadata| {
|
|
metadata.file_type().is_socket() && metadata.dev() == self.device && metadata.ino() == self.inode
|
|
}) {
|
|
let _ = fs::remove_file(&self.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<ScreenshotInit, Box<dyn std::error::Error>> {
|
|
let path = socket_path()?;
|
|
prepare_parent(&path)?;
|
|
let listener = bind_listener(&path)?;
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
|
let metadata = fs::symlink_metadata(&path)?;
|
|
let guard = IpcSocketGuard {
|
|
path: path.clone(),
|
|
device: metadata.dev(),
|
|
inode: metadata.ino(),
|
|
};
|
|
|
|
let (sender, receiver): (channel::SyncSender<IpcEvent>, channel::Channel<IpcEvent>) =
|
|
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::Closed(owner) => {
|
|
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.refresh_idle_inhibition();
|
|
return;
|
|
}
|
|
};
|
|
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 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.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.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::<PartOutcome>();
|
|
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::<Encoded>(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 signal = event_loop.get_signal();
|
|
thread::Builder::new()
|
|
.name("ferese-ipc-listener".to_owned())
|
|
.spawn(move || accept_connections(listener, sender, signal))?;
|
|
|
|
// 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)
|
|
})?;
|
|
|
|
// Readbacks are published by the render path, so a request whose outputs
|
|
// are not redrawn would otherwise wait forever. This re-arms to the exact
|
|
// remaining time whenever a request is outstanding, and idles otherwise.
|
|
event_loop
|
|
.handle()
|
|
.insert_source(timer::Timer::from_duration(DEADLINE_IDLE), |_, _, state| {
|
|
// Abandoned requests have already been answered, so their
|
|
// readbacks are simply dropped: they are not failed, because
|
|
// that would publish into a request that no longer exists.
|
|
for request in state.screenshot.expire() {
|
|
state
|
|
.pending_screencopies
|
|
.retain(|capture| capture.request_id() != Some(request));
|
|
}
|
|
let next = state.screenshot.next_deadline().unwrap_or(DEADLINE_IDLE);
|
|
timer::TimeoutAction::ToDuration(next)
|
|
})?;
|
|
|
|
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,
|
|
_guard: guard,
|
|
})
|
|
}
|
|
|
|
pub(crate) struct ScreenshotInit {
|
|
pub(crate) parts: PartSender,
|
|
pub(crate) worker: Worker,
|
|
pub(crate) _guard: IpcSocketGuard,
|
|
}
|
|
|
|
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal) {
|
|
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();
|
|
if let Err(error) = thread::Builder::new()
|
|
.name("ferese-ipc-client".to_owned())
|
|
.spawn(move || {
|
|
let _permit = permit;
|
|
serve_connection(stream, sender, signal, owner);
|
|
})
|
|
{
|
|
tracing::warn!(%error, "could not start IPC connection worker");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn try_acquire_connection(active: &Arc<AtomicUsize>) -> Option<ConnectionPermit> {
|
|
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() })
|
|
}
|
|
|
|
fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal, owner: u64) {
|
|
let native_portal = crate::handlers::window_capture::is_portal(&stream);
|
|
let _connection = IpcConnection {
|
|
owner,
|
|
sender: sender.clone(),
|
|
};
|
|
loop {
|
|
let request: Request = match read_frame(&mut stream) {
|
|
Ok(request) => request,
|
|
Err(ferese_ipc::FrameError::Io(error))
|
|
if matches!(
|
|
error.kind(),
|
|
io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe
|
|
) =>
|
|
{
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(%error, "closing invalid IPC connection");
|
|
return;
|
|
}
|
|
};
|
|
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 write_frame(&mut stream, &response).is_err() {
|
|
return;
|
|
}
|
|
continue;
|
|
}
|
|
let (response, receiver) = sync_channel(1);
|
|
let exit_requested = request.command == "exit";
|
|
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window");
|
|
let call = IpcEvent::Call(IpcCall {
|
|
native_portal,
|
|
owner,
|
|
request,
|
|
response,
|
|
});
|
|
if sender.try_send(call).is_err() {
|
|
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();
|
|
if let Err(error) = write_frame(&mut stream, &response) {
|
|
if screenshot_requested {
|
|
discard_undelivered_screenshot(&response);
|
|
}
|
|
tracing::debug!(%error, "IPC client disconnected before receiving its response");
|
|
return;
|
|
}
|
|
if exit_accepted {
|
|
// Acknowledge before stopping so feresectl never races process exit.
|
|
signal.stop();
|
|
signal.wakeup();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn u32_arg(args: &Value, key: &str) -> Result<u32, CommandError> {
|
|
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"
|
|
| "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<Value, CommandError> {
|
|
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());
|
|
}
|
|
"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-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(),
|
|
"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()),
|
|
"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::<Vec<_>>(),
|
|
}));
|
|
}
|
|
"get-keybindings" => {
|
|
let map = crate::config::physical_keymap(&self.input_settings).ok();
|
|
return Ok(json!(
|
|
self.bindings
|
|
.iter()
|
|
.filter_map(|binding| binding.guide_entry(map.as_ref()))
|
|
.take(256)
|
|
.collect::<Vec<_>>()
|
|
));
|
|
}
|
|
"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::<Vec<_>>()
|
|
})).collect::<Vec<_>>()
|
|
}));
|
|
}
|
|
"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::<crate::config::OutputLayout>(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, request: Request, response: SyncSender<Response>) {
|
|
// 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<OutputLayout> = 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(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,
|
|
));
|
|
}
|
|
|
|
// 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::<Vec<_>>();
|
|
crate::backends::direct::render_on(self, &outputs);
|
|
}
|
|
|
|
fn start_window_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
|
|
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(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,
|
|
});
|
|
}
|
|
|
|
pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) {
|
|
let action = self.screenshot.on_part(outcome.request, outcome.part, outcome.result);
|
|
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 } => {
|
|
let submitted = self
|
|
.screenshot_worker
|
|
.as_ref()
|
|
.is_some_and(|worker| worker.submit(Job { request, frames }).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"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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::<XdgToplevelSurfaceData>().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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>(),
|
|
}))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
outputs.sort_by_key(|output| output["id"].as_u64());
|
|
Value::Array(outputs)
|
|
}
|
|
}
|
|
|
|
fn geometry_arg(args: &Value) -> Result<Geometry, String> {
|
|
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<String>) -> Self {
|
|
Self {
|
|
code,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn direction_arg(args: &Value) -> Result<Direction, CommandError> {
|
|
match args.get("direction").and_then(Value::as_str) {
|
|
Some("left") => Ok(Direction::Left),
|
|
Some("right") => Ok(Direction::Right),
|
|
Some("up") => Ok(Direction::Up),
|
|
Some("down") => Ok(Direction::Down),
|
|
_ => Err(CommandError::new(
|
|
"invalid_argument",
|
|
"direction must be left, right, up, or down",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn workspace_arg(args: &Value) -> Result<u32, CommandError> {
|
|
args.get("index")
|
|
.and_then(Value::as_u64)
|
|
.and_then(|index| u32::try_from(index).ok())
|
|
.filter(|index| *index > 0)
|
|
.ok_or_else(|| CommandError::new("invalid_argument", "index must be a positive 32-bit integer"))
|
|
}
|
|
|
|
fn socket_path() -> Result<PathBuf, io::Error> {
|
|
env::var_os("XDG_RUNTIME_DIR")
|
|
.filter(|value| !value.is_empty())
|
|
.map(PathBuf::from)
|
|
.map(|directory| directory.join("ferese/control.sock"))
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
|
|
}
|
|
|
|
fn prepare_parent(socket: &Path) -> Result<(), io::Error> {
|
|
let parent = socket.parent().expect("the Ferese control socket always has a parent");
|
|
match fs::symlink_metadata(parent) {
|
|
Ok(metadata) => {
|
|
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("{} is not a real directory", parent.display()),
|
|
));
|
|
}
|
|
if metadata.uid() != effective_uid() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
format!("{} is owned by another user", parent.display()),
|
|
));
|
|
}
|
|
}
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir(parent)?,
|
|
Err(error) => return Err(error),
|
|
}
|
|
|
|
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
|
|
}
|
|
|
|
fn bind_listener(path: &Path) -> Result<UnixListener, io::Error> {
|
|
match UnixListener::bind(path) {
|
|
Ok(listener) => Ok(listener),
|
|
Err(error) if error.kind() == io::ErrorKind::AddrInUse => {
|
|
if UnixStream::connect(path).is_ok() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::AddrInUse,
|
|
"another Ferese IPC server is already running",
|
|
));
|
|
}
|
|
let metadata = fs::symlink_metadata(path)?;
|
|
if !metadata.file_type().is_socket() || metadata.uid() != effective_uid() {
|
|
return Err(error);
|
|
}
|
|
fs::remove_file(path)?;
|
|
UnixListener::bind(path)
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
fn peer_uid(stream: &UnixStream) -> Result<u32, io::Error> {
|
|
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
|
|
let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
|
|
|
// SAFETY: `credentials` and `length` point to initialized, correctly sized
|
|
// storage for Linux SO_PEERCRED, and the stream owns a live file descriptor.
|
|
let result = unsafe {
|
|
libc::getsockopt(
|
|
stream.as_raw_fd(),
|
|
libc::SOL_SOCKET,
|
|
libc::SO_PEERCRED,
|
|
(&raw mut credentials).cast(),
|
|
&raw mut length,
|
|
)
|
|
};
|
|
if result == -1 {
|
|
Err(io::Error::last_os_error())
|
|
} else {
|
|
Ok(credentials.uid)
|
|
}
|
|
}
|
|
|
|
fn effective_uid() -> u32 {
|
|
// SAFETY: `geteuid` has no preconditions and cannot fail.
|
|
unsafe { libc::geteuid() }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::io::{Read, Write};
|
|
use std::net::Shutdown;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
use super::*;
|
|
|
|
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 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 listener_reclaims_only_a_stale_owned_socket() {
|
|
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::<Vec<_>>();
|
|
|
|
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());
|
|
}
|
|
|
|
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()))
|
|
}
|
|
}
|