feat: wayland connection, niri IPC and valw doctor

This commit is contained in:
elwrcl committed 2026-09-29 23:28:51 +03:00
1 parent c6094307f5
commit 272f2d30ab
4 files changed
+799 -4

No files matched your search

+263
View File
@@ -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<String>) -> 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<Check> {
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<String>) -> 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");
}
}
+48 -4
View File
@@ -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::<Vec<_>>()
);
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(e) if e.downcast_ref::<Cancelled>().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(),
}
}
+61
View File
@@ -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<Response> {
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<Option<String>> {
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<String> {
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}"
);
}
}
+427
View File
@@ -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<State>,
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<LayerShell>,
pub screencopy: Option<ZwlrScreencopyManagerV1>,
pub viewporter: Option<WpViewporter>,
pub cursor_shape: Option<CursorShapeManager>,
pub cursor_device: Option<WpCursorShapeDeviceV1>,
pub keyboard: Option<wl_keyboard::WlKeyboard>,
pub pointer: Option<wl_pointer::WlPointer>,
}
impl Wayland {
pub fn connect() -> Result<Wayland> {
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::<State>(&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<Output> {
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<String> = 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<Self>,
_: &wl_surface::WlSurface,
_: i32,
) {
}
fn transform_changed(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: wl_output::Transform,
) {
}
fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
fn surface_enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: &wl_output::WlOutput,
) {
}
fn surface_leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &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<Self>, _: wl_output::WlOutput) {}
fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
}
impl LayerShellHandler for State {
fn closed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &LayerSurface) {}
fn configure(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &LayerSurface,
_: LayerSurfaceConfigure,
_: u32,
) {
}
}
impl SeatHandler for State {
fn seat_state(&mut self) -> &mut SeatState {
&mut self.seat
}
fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
fn new_capability(
&mut self,
_: &Connection,
qh: &QueueHandle<Self>,
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<Self>,
_: 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<Self>, _: wl_seat::WlSeat) {}
}
impl KeyboardHandler for State {
fn enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
_: u32,
_: &[u32],
_: &[Keysym],
) {
}
fn leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
_: u32,
) {
}
fn press_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: KeyEvent,
) {
}
fn repeat_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: KeyEvent,
) {
}
fn release_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: KeyEvent,
) {
}
fn update_modifiers(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: Modifiers,
_: RawModifiers,
_: u32,
) {
}
}
impl PointerHandler for State {
fn pointer_frame(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &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);