diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..c8a9462 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,263 @@ +use std::fmt; +use std::path::Path; + +use anyhow::{Result, bail}; + +use crate::config; +use crate::niri; +use crate::wayland::Wayland; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + Ok, + Warn, + Fail, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Check { + pub level: Level, + pub text: String, +} + +impl fmt::Display for Check { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let tag = match self.level { + Level::Ok => "ok", + Level::Warn => "warn", + Level::Fail => "fail", + }; + write!(f, "{tag:<5} {}", self.text) + } +} + +fn check(level: Level, text: impl Into) -> Check { + Check { + level, + text: text.into(), + } +} + +/// Globals valw can't work without: (interface, minimum version, used for). +const REQUIRED: [(&str, u32, &str); 7] = [ + ("wl_compositor", 1, "surfaces"), + ("wl_shm", 1, "pixel buffers"), + ("wl_seat", 1, "mouse and keyboard"), + ("zxdg_output_manager_v1", 2, "output names and positions"), + ("zwlr_layer_shell_v1", 1, "the selection overlay"), + ("zwlr_screencopy_manager_v1", 1, "screen capture"), + ("wp_viewporter", 1, "sharp overlays at fractional scale"), +]; + +/// Globals that enable a feature: (any of these interfaces, feature). +const OPTIONAL: [(&[&str], &str); 2] = [ + ( + &["wp_cursor_shape_manager_v1"], + "crosshair cursor in region mode", + ), + ( + &[ + "ext_data_control_manager_v1", + "zwlr_data_control_manager_v1", + ], + "clipboard copy", + ), +]; + +pub fn check_globals(globals: &[(String, u32)]) -> Vec { + let version = |name: &str| globals.iter().find(|(i, _)| i == name).map(|&(_, v)| v); + let mut checks = Vec::new(); + for (name, min, purpose) in REQUIRED { + checks.push(match version(name) { + Some(v) if v >= min => check(Level::Ok, format!("{name} v{v} ({purpose})")), + Some(v) => check( + Level::Fail, + format!("{name} v{v} is too old, need v{min} ({purpose})"), + ), + None => check(Level::Fail, format!("{name} missing ({purpose})")), + }); + } + for (names, feature) in OPTIONAL { + checks.push( + match names.iter().find_map(|&n| version(n).map(|v| (n, v))) { + Some((n, v)) => check(Level::Ok, format!("{n} v{v} ({feature})")), + None => check( + Level::Warn, + format!("{} missing ({feature} disabled)", names.join(" / ")), + ), + }, + ); + } + checks +} + +pub fn check_niri(version: Result) -> Check { + let (y, m) = niri::IPC_VERSION; + match version { + Err(e) => check( + Level::Warn, + format!("niri IPC unavailable ({e:#}); using the first output for `screen`"), + ), + Ok(v) => match niri::parse_version(&v) { + Some(found) if found == niri::IPC_VERSION => check(Level::Ok, format!("niri {v}")), + _ => check( + Level::Warn, + format!( + "niri {v}, but valw was built against niri-ipc {y}.{m}; IPC replies may not parse" + ), + ), + }, + } +} + +pub fn check_config(path: &Path) -> Check { + match config::load(path) { + Ok(_) if path.exists() => check(Level::Ok, format!("config {}", path.display())), + Ok(_) => check( + Level::Ok, + format!("config {} not found, using defaults", path.display()), + ), + Err(e) => check(Level::Fail, format!("config: {e:#}")), + } +} + +pub fn check_save_dir(dir: &Path) -> Check { + let probe = dir.join(".valw-doctor"); + let result = std::fs::create_dir_all(dir) + .and_then(|()| std::fs::write(&probe, b"")) + .and_then(|()| std::fs::remove_file(&probe)); + match result { + Ok(()) => check( + Level::Ok, + format!("save directory {} is writable", dir.display()), + ), + Err(e) => check( + Level::Fail, + format!("save directory {}: {e}", dir.display()), + ), + } +} + +/// Runs every check and prints one line each. Fails if any check failed. +pub fn run() -> Result<()> { + let mut checks = Vec::new(); + match Wayland::connect() { + Ok(wl) => { + checks.extend(check_globals(&wl.global_list())); + for o in wl.outputs() { + let g = &o.geom; + checks.push(check( + Level::Ok, + format!( + "output {}: {}x{} logical at {},{}, transform {:?}", + g.name, g.width, g.height, g.x, g.y, o.transform + ), + )); + } + } + Err(e) => checks.push(check(Level::Fail, format!("wayland: {e:#}"))), + } + checks.push(check_niri(niri::version())); + let config_path = config::default_path(); + checks.push(check_config(&config_path)); + if let Ok(config) = config::load(&config_path) { + checks.push(check_save_dir(&config.save_dir())); + } + + for c in &checks { + println!("{c}"); + } + let failed = checks.iter().filter(|c| c.level == Level::Fail).count(); + if failed > 0 { + bail!("{failed} check(s) failed"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn globals(list: &[(&str, u32)]) -> Vec<(String, u32)> { + list.iter().map(|&(n, v)| (n.to_string(), v)).collect() + } + + fn niri_26_04() -> Vec<(String, u32)> { + globals(&[ + ("wl_compositor", 6), + ("wl_shm", 2), + ("wl_seat", 9), + ("zxdg_output_manager_v1", 3), + ("zwlr_layer_shell_v1", 5), + ("zwlr_screencopy_manager_v1", 3), + ("wp_viewporter", 1), + ("wp_cursor_shape_manager_v1", 2), + ("zwlr_data_control_manager_v1", 2), + ("ext_data_control_manager_v1", 1), + ]) + } + + #[test] + fn all_present_is_all_ok() { + let checks = check_globals(&niri_26_04()); + assert!(checks.iter().all(|c| c.level == Level::Ok), "{checks:#?}"); + assert!( + checks + .iter() + .any(|c| c.text == "ext_data_control_manager_v1 v1 (clipboard copy)") + ); + } + + #[test] + fn missing_required_fails_missing_optional_warns() { + let list: Vec<_> = niri_26_04() + .into_iter() + .filter(|(n, _)| n != "zwlr_screencopy_manager_v1" && n != "wp_cursor_shape_manager_v1") + .collect(); + let checks = check_globals(&list); + assert!(checks.contains(&check( + Level::Fail, + "zwlr_screencopy_manager_v1 missing (screen capture)" + ))); + assert!(checks.contains(&check( + Level::Warn, + "wp_cursor_shape_manager_v1 missing (crosshair cursor in region mode disabled)" + ))); + } + + #[test] + fn old_version_fails() { + let mut list = niri_26_04(); + list.retain(|(n, _)| n != "zxdg_output_manager_v1"); + list.push(("zxdg_output_manager_v1".into(), 1)); + assert!(check_globals(&list).contains(&check( + Level::Fail, + "zxdg_output_manager_v1 v1 is too old, need v2 (output names and positions)" + ))); + } + + #[test] + fn niri_versions() { + assert_eq!(check_niri(Ok("26.04 (Nixpkgs)".into())).level, Level::Ok); + assert_eq!(check_niri(Ok("25.11".into())).level, Level::Warn); + assert_eq!( + check_niri(Err(anyhow::anyhow!("no socket"))).level, + Level::Warn + ); + } + + #[test] + fn save_dir_checks() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(check_save_dir(&dir.path().join("new")).level, Level::Ok); + let file = dir.path().join("file"); + std::fs::write(&file, b"").unwrap(); + assert_eq!(check_save_dir(&file).level, Level::Fail); + } + + #[test] + fn line_format() { + assert_eq!(check(Level::Warn, "x").to_string(), "warn x"); + assert_eq!(check(Level::Ok, "x").to_string(), "ok x"); + } +} diff --git a/src/main.rs b/src/main.rs index c5e67c0..ee44c9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,20 +3,64 @@ mod config; mod detach; +mod doctor; mod error; mod frame; mod lock; mod log; +mod niri; mod output; mod render; mod selection; +mod wayland; -use clap::Parser; +use std::process::ExitCode; + +use anyhow::Result; +use chrono::Local; +use clap::{Parser, Subcommand}; + +use crate::error::Cancelled; #[derive(Parser)] #[command(version, about = "macOS-style screenshots for niri")] -struct Cli {} +struct Cli { + #[command(subcommand)] + command: Command, +} -fn main() { - Cli::parse(); +#[derive(Subcommand)] +enum Command { + /// Report what the compositor and system support. + Doctor, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let log = log::init(&log::default_dir(), Local::now()); + log::install_panic_hook(log.clone()); + tracing::info!( + "valw {} {:?}", + env!("CARGO_PKG_VERSION"), + std::env::args().collect::>() + ); + + match run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(e) if e.downcast_ref::().is_some() => { + tracing::info!("cancelled by the user"); + ExitCode::from(3) + } + Err(e) => { + tracing::error!("{e:#}"); + eprint!("{}", error::render(&e, log.as_deref())); + ExitCode::FAILURE + } + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Doctor => doctor::run(), + } } diff --git a/src/niri.rs b/src/niri.rs new file mode 100644 index 0000000..351dabf --- /dev/null +++ b/src/niri.rs @@ -0,0 +1,61 @@ +use anyhow::{Context, Result, anyhow}; +use niri_ipc::{Request, Response, socket::Socket}; + +/// The niri release the pinned `niri-ipc` crate belongs to. Keep in sync +/// with the `niri-ipc = "=X.Y.0"` line in Cargo.toml (a test checks this). +pub const IPC_VERSION: (u32, u32) = (26, 4); + +fn request(request: Request) -> Result { + let mut socket = + Socket::connect().context("could not connect to niri (is NIRI_SOCKET set?)")?; + socket + .send(request) + .context("niri IPC request failed")? + .map_err(|e| anyhow!("niri replied with an error: {e}")) +} + +/// Name of the output niri has focused, if any. +pub fn focused_output() -> Result> { + match request(Request::FocusedOutput)? { + Response::FocusedOutput(output) => Ok(output.map(|o| o.name)), + other => Err(anyhow!("unexpected niri reply: {other:?}")), + } +} + +/// niri's version string, e.g. "26.04 (Nixpkgs)". +pub fn version() -> Result { + match request(Request::Version)? { + Response::Version(v) => Ok(v), + other => Err(anyhow!("unexpected niri reply: {other:?}")), + } +} + +/// `(year, month)` from a niri version string like "26.04 (Nixpkgs)". +pub fn parse_version(v: &str) -> Option<(u32, u32)> { + let (year, rest) = v.split_once('.')?; + let month: String = rest.chars().take_while(char::is_ascii_digit).collect(); + Some((year.parse().ok()?, month.parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_versions() { + assert_eq!(parse_version("26.04 (Nixpkgs)"), Some((26, 4))); + assert_eq!(parse_version("25.11"), Some((25, 11))); + assert_eq!(parse_version("26.04.1-dev"), Some((26, 4))); + assert_eq!(parse_version("unknown"), None); + } + + #[test] + fn ipc_version_matches_cargo_toml() { + let (y, m) = IPC_VERSION; + let pin = format!("niri-ipc = \"={y}.{m}.0\""); + assert!( + include_str!("../Cargo.toml").contains(&pin), + "Cargo.toml should pin {pin}" + ); + } +} diff --git a/src/wayland.rs b/src/wayland.rs new file mode 100644 index 0000000..3e95a4d --- /dev/null +++ b/src/wayland.rs @@ -0,0 +1,427 @@ +use std::time::Instant; + +use anyhow::{Context, Result, bail}; +use rustix::event::{PollFd, PollFlags, Timespec, poll}; +use smithay_client_toolkit::{ + compositor::{CompositorHandler, CompositorState}, + delegate_registry, + output::{OutputHandler, OutputState}, + registry::{ProvidesRegistryState, RegistryState}, + registry_handlers, + seat::{ + Capability, SeatHandler, SeatState, + keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers}, + pointer::{PointerEvent, PointerHandler, cursor_shape::CursorShapeManager}, + }, + shell::wlr_layer::{LayerShell, LayerShellHandler, LayerSurface, LayerSurfaceConfigure}, + shm::{Shm, ShmHandler}, +}; +use wayland_client::{ + Connection, EventQueue, QueueHandle, + globals::{GlobalList, registry_queue_init}, + protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, +}; +use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; +use wayland_protocols::wp::viewporter::client::{ + wp_viewport::WpViewport, wp_viewporter::WpViewporter, +}; +use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; + +use crate::error::HintExt; +use crate::frame::OutputGeom; + +/// An output as valw sees it. +#[derive(Debug, Clone)] +pub struct Output { + pub wl: wl_output::WlOutput, + pub geom: OutputGeom, + pub transform: wl_output::Transform, +} + +pub struct Wayland { + pub conn: Connection, + pub queue: EventQueue, + pub globals: GlobalList, + pub state: State, +} + +pub struct State { + registry: RegistryState, + pub outputs: OutputState, + pub seat: SeatState, + pub shm: Shm, + pub compositor: CompositorState, + pub layer_shell: Option, + pub screencopy: Option, + pub viewporter: Option, + pub cursor_shape: Option, + pub cursor_device: Option, + pub keyboard: Option, + pub pointer: Option, +} + +impl Wayland { + pub fn connect() -> Result { + let conn = Connection::connect_to_env() + .context("could not connect to the Wayland compositor") + .hint("valw needs a Wayland session; is WAYLAND_DISPLAY set?")?; + let (globals, mut queue) = registry_queue_init::(&conn) + .context("could not read the compositor's globals")?; + let qh = queue.handle(); + + let mut state = State { + registry: RegistryState::new(&globals), + outputs: OutputState::new(&globals, &qh), + seat: SeatState::new(&globals, &qh), + shm: Shm::bind(&globals, &qh).context("compositor has no wl_shm")?, + compositor: CompositorState::bind(&globals, &qh) + .context("compositor has no wl_compositor")?, + layer_shell: LayerShell::bind(&globals, &qh).ok(), + screencopy: globals.bind(&qh, 1..=3, ()).ok(), + viewporter: globals.bind(&qh, 1..=1, ()).ok(), + cursor_shape: CursorShapeManager::bind(&globals, &qh).ok(), + cursor_device: None, + keyboard: None, + pointer: None, + }; + // Two round trips: one for wl_output, one for the xdg-output details. + queue + .roundtrip(&mut state) + .context("Wayland round trip failed")?; + queue + .roundtrip(&mut state) + .context("Wayland round trip failed")?; + + let wl = Wayland { + conn, + queue, + globals, + state, + }; + wl.log_environment(); + Ok(wl) + } + + /// All outputs with a known name and logical geometry. + pub fn outputs(&self) -> Vec { + self.state + .outputs + .outputs() + .filter_map(|wl| { + let info = self.state.outputs.info(&wl)?; + let (x, y) = info.logical_position?; + let (width, height) = info.logical_size?; + Some(Output { + geom: OutputGeom { + name: info + .name + .clone() + .unwrap_or_else(|| format!("output-{}", info.id)), + x, + y, + width, + height, + }, + transform: info.transform, + wl, + }) + }) + .collect() + } + + /// `(interface, version)` of every global, sorted. + pub fn global_list(&self) -> Vec<(String, u32)> { + let mut list: Vec<_> = self + .globals + .contents() + .clone_list() + .into_iter() + .map(|g| (g.interface, g.version)) + .collect(); + list.sort(); + list.dedup(); + list + } + + fn log_environment(&self) { + let globals: Vec = self + .global_list() + .into_iter() + .map(|(interface, version)| format!("{interface} v{version}")) + .collect(); + tracing::info!("globals: {}", globals.join(", ")); + for o in self.outputs() { + let g = &o.geom; + tracing::info!( + "output {} at {},{} size {}x{} (logical) transform {:?}", + g.name, + g.x, + g.y, + g.width, + g.height, + o.transform + ); + } + } + + /// Dispatches events until `done` returns true or `deadline` passes. + pub fn dispatch_until( + &mut self, + deadline: Instant, + mut done: impl FnMut(&State) -> bool, + ) -> Result<()> { + loop { + self.queue + .dispatch_pending(&mut self.state) + .context("Wayland dispatch failed")?; + if done(&self.state) { + return Ok(()); + } + self.queue.flush().context("Wayland flush failed")?; + let Some(guard) = self.queue.prepare_read() else { + continue; + }; + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + bail!("the compositor did not answer in time"); + } + let timeout = Timespec::try_from(left).expect("duration fits"); + let fd = guard.connection_fd(); + let mut fds = [PollFd::new(&fd, PollFlags::IN)]; + match poll(&mut fds, Some(&timeout)) { + Ok(0) => continue, + Ok(_) => { + guard.read().context("Wayland read failed")?; + } + Err(rustix::io::Errno::INTR) => continue, + Err(e) => return Err(e).context("poll failed"), + } + } + } + + /// Dispatches events until `done` returns true. Waits as long as it takes. + pub fn dispatch_blocking(&mut self, mut done: impl FnMut(&State) -> bool) -> Result<()> { + while !done(&self.state) { + self.queue + .blocking_dispatch(&mut self.state) + .context("Wayland dispatch failed")?; + } + Ok(()) + } +} + +impl CompositorHandler for State { + fn scale_factor_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: i32, + ) { + } + + fn transform_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: wl_output::Transform, + ) { + } + + fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: u32) {} + + fn surface_enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } + + fn surface_leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } +} + +impl OutputHandler for State { + fn output_state(&mut self) -> &mut OutputState { + &mut self.outputs + } + + fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + + fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} +} + +impl LayerShellHandler for State { + fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) {} + + fn configure( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &LayerSurface, + _: LayerSurfaceConfigure, + _: u32, + ) { + } +} + +impl SeatHandler for State { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat + } + + fn new_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} + + fn new_capability( + &mut self, + _: &Connection, + qh: &QueueHandle, + seat: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Keyboard && self.keyboard.is_none() { + self.keyboard = self.seat.get_keyboard(qh, &seat, None).ok(); + } + if capability == Capability::Pointer + && self.pointer.is_none() + && let Ok(pointer) = self.seat.get_pointer(qh, &seat) + { + self.cursor_device = self + .cursor_shape + .as_ref() + .map(|m| m.get_shape_device(&pointer, qh)); + self.pointer = Some(pointer); + } + } + + fn remove_capability( + &mut self, + _: &Connection, + _: &QueueHandle, + _: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Keyboard + && let Some(k) = self.keyboard.take() + { + k.release(); + } + if capability == Capability::Pointer { + if let Some(d) = self.cursor_device.take() { + d.destroy(); + } + if let Some(p) = self.pointer.take() { + p.release(); + } + } + } + + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} +} + +impl KeyboardHandler for State { + fn enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + _: &[u32], + _: &[Keysym], + ) { + } + + fn leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + ) { + } + + fn press_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KeyEvent, + ) { + } + + fn repeat_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KeyEvent, + ) { + } + + fn release_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KeyEvent, + ) { + } + + fn update_modifiers( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: Modifiers, + _: RawModifiers, + _: u32, + ) { + } +} + +impl PointerHandler for State { + fn pointer_frame( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_pointer::WlPointer, + _: &[PointerEvent], + ) { + } +} + +impl ShmHandler for State { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm + } +} + +impl ProvidesRegistryState for State { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry + } + registry_handlers![OutputState, SeatState]; +} + +delegate_registry!(State); +smithay_client_toolkit::delegate_dispatch2!(State); +wayland_client::delegate_noop!(State: ZwlrScreencopyManagerV1); +wayland_client::delegate_noop!(State: WpViewporter); +wayland_client::delegate_noop!(State: WpViewport);