Files
valw/src/host.rs
T
2026-09-30 13:28:19 +03:00

471 lines
15 KiB
Rust

//! The preview host: one process that owns every thumbnail, started on
//! demand by a capture and gone once its last thumbnail is.
use std::collections::BTreeSet;
use std::io::{ErrorKind, Read, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use smithay_client_toolkit::reexports::calloop::{
EventLoop, Interest, LoopHandle, Mode, PostAction,
generic::Generic,
timer::{TimeoutAction, Timer},
};
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
use smithay_client_toolkit::seat::pointer::{BTN_LEFT, PointerEvent, PointerEventKind};
use smithay_client_toolkit::shell::wlr_layer::LayerSurface;
use wayland_client::{Connection, QueueHandle, protocol::wl_surface::WlSurface};
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{
Shape, WpCursorShapeDeviceV1,
};
use crate::ipc::{self, Reply, Request};
use crate::lock::Lock;
use crate::stack;
use crate::thumbnail::{Action, Parts, Thumbnail};
use crate::wayland::{State, Wayland};
/// A host nobody connects to within this time gives up.
const STARTUP_GRACE: Duration = Duration::from_secs(2);
/// Connection bookkeeping and the exit rule, without any Wayland.
#[derive(Debug)]
pub struct Core {
clients: BTreeSet<u64>,
hiding: BTreeSet<u64>,
seen_client: bool,
started: Instant,
}
impl Core {
pub fn new(now: Instant) -> Core {
Core {
clients: BTreeSet::new(),
hiding: BTreeSet::new(),
seen_client: false,
started: now,
}
}
pub fn connect(&mut self, id: u64) {
self.clients.insert(id);
self.seen_client = true;
}
/// Returns true if this request is what hides the thumbnails.
pub fn hide(&mut self, id: u64) -> bool {
let was_hidden = self.hidden();
self.hiding.insert(id);
!was_hidden
}
/// Returns true if this disconnect is what shows the thumbnails again.
pub fn disconnect(&mut self, id: u64) -> bool {
self.clients.remove(&id);
self.hiding.remove(&id) && self.hiding.is_empty()
}
pub fn hidden(&self) -> bool {
!self.hiding.is_empty()
}
/// Nothing to show and nobody talking to us. A fresh host waits a little
/// for the capture that started it to connect.
pub fn should_exit(&self, thumbnails: usize, now: Instant) -> bool {
thumbnails == 0
&& self.clients.is_empty()
&& (self.seen_client || now.duration_since(self.started) > STARTUP_GRACE)
}
}
pub struct Host {
core: Core,
/// Oldest first.
thumbs: Vec<Thumbnail>,
next_id: u64,
timeout: Duration,
conn: Connection,
handle: LoopHandle<'static, State>,
qh: QueueHandle<State>,
}
/// Runs the host until its last thumbnail closes. A second host exits
/// right away: the first one owns the socket.
pub fn run() -> Result<()> {
let Ok(_lock) = Lock::acquire(&ipc::host_lock_path()) else {
tracing::info!("another preview host is running");
return Ok(());
};
let socket = ipc::socket_path();
// Safe under the lock: whatever is there is left over from a crash.
let _ = std::fs::remove_file(&socket);
let listener = UnixListener::bind(&socket)
.with_context(|| format!("could not listen on {}", socket.display()))?;
listener.set_nonblocking(true)?;
let config = crate::config::load(&crate::config::default_path())?;
let Wayland {
conn,
queue,
mut state,
..
} = Wayland::connect()?;
let mut event_loop: EventLoop<'static, State> =
EventLoop::try_new().context("could not create the event loop")?;
let handle = event_loop.handle();
let qh = queue.handle();
WaylandSource::new(conn.clone(), queue)
.insert(handle.clone())
.map_err(|e| anyhow::anyhow!("could not watch the Wayland socket: {e}"))?;
state.preview = Some(Host {
core: Core::new(Instant::now()),
thumbs: Vec::new(),
next_id: 0,
timeout: Duration::from_secs(config.preview.timeout_secs),
conn,
handle: handle.clone(),
qh,
});
let mut next_client = 0u64;
handle
.insert_source(
Generic::new(listener, Interest::READ, Mode::Level),
move |_, listener, state| {
while let Ok((stream, _)) = listener.accept() {
next_client += 1;
if let Some(host) = &mut state.preview {
host.add_client(stream, next_client);
}
}
Ok(PostAction::Continue)
},
)
.map_err(|e| anyhow::anyhow!("could not watch the socket: {e}"))?;
tracing::info!("preview host listening on {}", socket.display());
let signal = event_loop.get_signal();
event_loop
.run(Duration::from_millis(500), &mut state, |state| {
let host = state.preview.as_mut().expect("host state");
host.tidy();
if host.core.should_exit(host.thumbs.len(), Instant::now()) {
signal.stop();
}
})
.context("preview host event loop failed")?;
let _ = std::fs::remove_file(&socket);
tracing::info!("preview host done");
Ok(())
}
impl Host {
fn add_client(&mut self, stream: UnixStream, id: u64) {
if stream.set_nonblocking(true).is_err() {
return;
}
self.core.connect(id);
let mut buf = Vec::new();
let inserted = self.handle.insert_source(
Generic::new(stream, Interest::READ, Mode::Level),
move |_, stream, state| {
let mut chunk = [0u8; 4096];
loop {
let mut reader: &UnixStream = stream;
match reader.read(&mut chunk) {
Ok(0) => {
disconnect(state, id);
return Ok(PostAction::Remove);
}
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
Err(_) => {
disconnect(state, id);
return Ok(PostAction::Remove);
}
}
}
while let Some(end) = buf.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = buf.drain(..=end).collect();
let reply = match ipc::parse_request(&String::from_utf8_lossy(&line)) {
Ok(request) => handle(state, id, request),
Err(e) => Reply::error(e),
};
let mut writer: &UnixStream = stream;
let _ = writer.write_all(ipc::encode(&reply).as_bytes());
}
Ok(PostAction::Continue)
},
);
if inserted.is_err() {
self.core.disconnect(id);
}
}
/// Drops finished thumbnails and stacks the rest per output.
fn tidy(&mut self) {
let before = self.thumbs.len();
self.thumbs.retain(|t| !t.is_finished());
if self.thumbs.len() != before {
self.restack();
let _ = self.conn.flush();
}
}
fn restack(&mut self) {
let outputs: BTreeSet<String> = self.thumbs.iter().map(|t| t.output.clone()).collect();
for output in outputs {
let newest_first: Vec<usize> = (0..self.thumbs.len())
.rev()
.filter(|&i| self.thumbs[i].output == output)
.collect();
let heights: Vec<u32> = newest_first
.iter()
.map(|&i| self.thumbs[i].size.1)
.collect();
for (&i, margin) in newest_first.iter().zip(stack::bottom_margins(&heights)) {
self.thumbs[i].set_margin(margin);
}
}
}
fn thumb(&mut self, surface: &WlSurface) -> Option<&mut Thumbnail> {
self.thumbs.iter_mut().find(|t| t.is(surface))
}
pub fn configure(&mut self, layer: &LayerSurface, qh: &QueueHandle<State>) {
if let Some(t) = self.thumbs.iter_mut().find(|t| t.is_layer(layer)) {
t.configure(qh);
}
}
pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle<State>) {
if let Some(t) = self.thumb(surface) {
t.frame_done(qh);
}
}
pub fn closed(&mut self, layer: &LayerSurface) {
self.thumbs.retain(|t| !t.is_layer(layer));
}
pub fn pointer(
&mut self,
events: &[PointerEvent],
cursor: Option<&WpCursorShapeDeviceV1>,
qh: &QueueHandle<State>,
) {
for event in events {
let Some(t) = self.thumb(&event.surface) else {
continue;
};
let (x, y) = event.position;
match event.kind {
PointerEventKind::Enter { serial } => {
if let Some(cursor) = cursor {
cursor.set_shape(serial, Shape::Pointer);
}
}
PointerEventKind::Press {
button: BTN_LEFT, ..
} => t.press(x, y),
PointerEventKind::Motion { .. } => t.motion(x, qh),
PointerEventKind::Release {
button: BTN_LEFT, ..
} => {
let action = t.release(x, y, qh);
if action == Some(Action::OpenEditor) {
open_editor(&t.path);
}
}
_ => {}
}
}
}
fn expire(&mut self, id: u64) {
let qh = self.qh.clone();
if let Some(t) = self.thumbs.iter_mut().find(|t| t.id == id) {
t.expire(&qh);
}
}
fn set_hidden(&mut self, hidden: bool) {
let qh = self.qh.clone();
for t in &mut self.thumbs {
if hidden {
t.hide(&qh);
} else {
t.show(&qh);
}
}
}
}
fn disconnect(state: &mut State, id: u64) {
let Some(host) = &mut state.preview else {
return;
};
if host.core.disconnect(id) {
host.set_hidden(false);
let _ = host.conn.flush();
}
}
fn handle(state: &mut State, id: u64, request: Request) -> Reply {
match request {
Request::Hide => {
let host = state.preview.as_mut().expect("host state");
if host.core.hide(id) {
host.set_hidden(true);
// The capture must not start until the compositor has
// actually taken the thumbnails off the screen.
if let Err(e) = host.conn.roundtrip() {
return Reply::error(format!("Wayland round trip failed: {e}"));
}
}
Reply::ok()
}
Request::Add { path, output } => match add(state, &path, &output) {
Ok(()) => Reply::ok(),
Err(e) => {
tracing::warn!("could not show a preview: {e:#}");
Reply::error(format!("{e:#}"))
}
},
}
}
fn add(state: &mut State, path: &Path, output_name: &str) -> Result<()> {
let image = image::open(path)
.with_context(|| format!("could not read {}", path.display()))?
.to_rgba8();
let outputs = state.output_list();
let output = outputs
.iter()
.find(|o| o.geom.name == output_name)
.or_else(|| outputs.first())
.context("the compositor reported no outputs")?;
let parts = Parts {
compositor: &state.compositor,
layer_shell: state
.layer_shell
.as_ref()
.context("compositor has no wlr-layer-shell")?,
viewporter: state
.viewporter
.as_ref()
.context("compositor has no wp-viewporter")?,
shm: &state.shm,
};
let host = state.preview.as_mut().expect("host state");
host.next_id += 1;
let id = host.next_id;
let mut thumb = Thumbnail::new(id, path.to_path_buf(), &image, output, &parts, &host.qh)?;
if host.core.hidden() {
thumb.hide(&host.qh);
}
host.thumbs.push(thumb);
// Beyond MAX on this output, the oldest slide away.
let qh = host.qh.clone();
let open: Vec<usize> = (0..host.thumbs.len())
.filter(|&i| host.thumbs[i].output == output.geom.name && !host.thumbs[i].is_closing())
.collect();
for &i in open.iter().take(stack::excess(open.len())) {
host.thumbs[i].slide_out(&qh);
}
host.restack();
host.handle
.insert_source(Timer::from_duration(host.timeout), move |_, _, state| {
if let Some(host) = &mut state.preview {
host.expire(id);
}
TimeoutAction::Drop
})
.map_err(|e| anyhow::anyhow!("could not start the preview timer: {e}"))?;
let _ = host.conn.flush();
Ok(())
}
/// Opens the screenshot in Satty; saving there overwrites the file.
fn open_editor(path: &Path) {
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
let mut command = Command::new("satty");
command
.arg("--filename")
.arg(path)
.arg("--output-filename")
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
// SAFETY: setsid is async-signal-safe.
unsafe {
command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into));
}
if let Err(e) = command.spawn() {
tracing::warn!("could not start satty: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hidden_until_every_hider_leaves() {
let mut core = Core::new(Instant::now());
core.connect(1);
core.connect(2);
assert!(core.hide(1), "first hide hides");
assert!(!core.hide(2), "second hide changes nothing");
assert!(!core.disconnect(1), "still hidden by 2");
assert!(core.hidden());
assert!(core.disconnect(2), "last hider leaving shows");
assert!(!core.hidden());
}
#[test]
fn a_client_that_never_hid_does_not_show() {
let mut core = Core::new(Instant::now());
core.connect(1);
core.connect(2);
core.hide(1);
assert!(!core.disconnect(2));
assert!(core.hidden());
}
#[test]
fn exits_only_when_empty_and_alone() {
let now = Instant::now();
let mut core = Core::new(now);
assert!(!core.should_exit(0, now), "waits for its first client");
assert!(
core.should_exit(0, now + Duration::from_secs(3)),
"gives up after the grace period"
);
core.connect(1);
assert!(!core.should_exit(0, now), "a client is connected");
core.disconnect(1);
assert!(!core.should_exit(1, now), "a thumbnail is up");
assert!(core.should_exit(0, now));
}
#[test]
fn a_second_host_gives_up_quietly() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("valw-preview.lock");
let _first = Lock::acquire(&path).unwrap();
assert!(Lock::acquire(&path).is_err());
}
}