main.rs raw

//! `quibblectl` — the command-line remote control for the quibble compositor.
//!
//! Each subcommand maps to a single [`quibble_proto::Request`] sent over the
//! compositor's control socket; the reply is printed (or, for screenshots,
//! written out as a PNG).

use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;

use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand, ValueEnum};

use quibble_proto::{Axis, ButtonState, KeyState, Layout, Rect, Request, Response};

#[derive(Parser)]
#[command(
    name = "quibblectl",
    about = "Remote control for the quibble headless Wayland compositor"
)]
struct Cli {
    /// Path to the compositor control socket. Defaults to $QUIBBLE_SOCKET, or a
    /// path derived from $WAYLAND_DISPLAY.
    #[arg(long, global = true)]
    socket: Option<PathBuf>,

    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Check that the compositor is alive.
    Ping,
    /// Capture a screenshot to a PNG file (or stdout with `-o -`).
    Screenshot {
        /// Output file, or `-` for stdout.
        #[arg(short, long, default_value = "screenshot.png")]
        output: String,
        /// Restrict to a region `X,Y,W,H` in logical coordinates.
        #[arg(long)]
        region: Option<String>,
    },
    /// List all mapped windows.
    ListWindows,
    /// Block until a window matching the filters maps.
    WaitWindow {
        #[arg(long)]
        app_id: Option<String>,
        #[arg(long)]
        title: Option<String>,
        #[arg(long, default_value_t = 5000)]
        timeout_ms: u64,
    },
    /// Give keyboard focus to a window.
    Focus { id: u64 },
    /// Move a window (floating layout).
    Move { id: u64, x: i32, y: i32 },
    /// Resize a window (floating layout).
    Resize { id: u64, w: i32, h: i32 },
    /// Switch the layout policy.
    Layout { layout: LayoutArg },
    /// Resize/rescale the virtual output.
    SetOutput {
        width: i32,
        height: i32,
        #[arg(long, default_value_t = 1.0)]
        scale: f64,
    },
    /// Move the pointer to an absolute location.
    PointerMove { x: f64, y: f64 },
    /// Press or release a pointer button (evdev code; default left=272).
    PointerButton {
        state: PressArg,
        #[arg(default_value_t = 272)]
        button: u32,
    },
    /// Click (press then release) a pointer button.
    Click {
        #[arg(default_value_t = 272)]
        button: u32,
    },
    /// Emit a scroll event.
    Axis { axis: AxisArg, value: f64 },
    /// Press or release a key (raw evdev keycode).
    Key { keycode: u32, state: PressArg },
    /// Type a UTF-8 string.
    Type { text: String },
    /// Begin a touch contact.
    TouchDown { slot: u32, x: f64, y: f64 },
    /// Move a touch contact.
    TouchMotion { slot: u32, x: f64, y: f64 },
    /// End a touch contact.
    TouchUp { slot: u32 },
    /// Cancel all touch contacts.
    TouchCancel,
    /// Read the clipboard selection.
    ClipboardGet,
    /// Set the clipboard selection.
    ClipboardSet { text: String },
    /// Open or close the monitor window.
    Monitor { state: OnOffArg },
    /// Ask the compositor to shut down.
    Quit,
}

#[derive(Clone, Copy, ValueEnum)]
enum LayoutArg {
    Tiled,
    Floating,
}

#[derive(Clone, Copy, ValueEnum)]
enum PressArg {
    Press,
    Release,
}

#[derive(Clone, Copy, ValueEnum)]
enum AxisArg {
    Vertical,
    Horizontal,
}

#[derive(Clone, Copy, ValueEnum)]
enum OnOffArg {
    On,
    Off,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    let socket = resolve_socket(cli.socket)?;

    // Some subcommands expand into more than one request (e.g. click).
    let requests = build_requests(&cli.command)?;
    for request in requests {
        let response = round_trip(&socket, &request)?;
        handle_response(&cli.command, response)?;
    }
    Ok(())
}

/// Locate the control socket from the flag, `$QUIBBLE_SOCKET`, or
/// `$WAYLAND_DISPLAY`.
fn resolve_socket(explicit: Option<PathBuf>) -> Result<PathBuf> {
    if let Some(path) = explicit {
        return Ok(path);
    }
    if let Some(path) = std::env::var_os("QUIBBLE_SOCKET") {
        return Ok(PathBuf::from(path));
    }
    if let Ok(display) = std::env::var("WAYLAND_DISPLAY") {
        let dir = std::env::var_os("XDG_RUNTIME_DIR")
            .map(PathBuf::from)
            .unwrap_or_else(std::env::temp_dir);
        return Ok(dir.join(format!("quibble-{display}.sock")));
    }
    bail!("no control socket: pass --socket, or set QUIBBLE_SOCKET or WAYLAND_DISPLAY")
}

fn build_requests(command: &Command) -> Result<Vec<Request>> {
    let one = |r| Ok(vec![r]);
    match command {
        Command::Ping => one(Request::Ping),
        Command::Screenshot { region, .. } => one(Request::Screenshot {
            region: region.as_deref().map(parse_region).transpose()?,
        }),
        Command::ListWindows => one(Request::ListWindows),
        Command::WaitWindow {
            app_id,
            title,
            timeout_ms,
        } => one(Request::WaitForWindow {
            app_id: app_id.clone(),
            title: title.clone(),
            timeout_ms: *timeout_ms,
        }),
        Command::Focus { id } => one(Request::FocusWindow {
            id: quibble_proto::WindowId(*id),
        }),
        Command::Move { id, x, y } => one(Request::MoveWindow {
            id: quibble_proto::WindowId(*id),
            x: *x,
            y: *y,
        }),
        Command::Resize { id, w, h } => one(Request::ResizeWindow {
            id: quibble_proto::WindowId(*id),
            w: *w,
            h: *h,
        }),
        Command::Layout { layout } => one(Request::SetLayout {
            layout: match layout {
                LayoutArg::Tiled => Layout::Tiled,
                LayoutArg::Floating => Layout::Floating,
            },
        }),
        Command::SetOutput {
            width,
            height,
            scale,
        } => one(Request::SetOutput {
            width: *width,
            height: *height,
            scale: *scale,
        }),
        Command::PointerMove { x, y } => one(Request::PointerMotion { x: *x, y: *y }),
        Command::PointerButton { state, button } => one(Request::PointerButton {
            button: *button,
            state: to_button_state(*state),
        }),
        Command::Click { button } => Ok(vec![
            Request::PointerButton {
                button: *button,
                state: ButtonState::Pressed,
            },
            Request::PointerButton {
                button: *button,
                state: ButtonState::Released,
            },
        ]),
        Command::Axis { axis, value } => one(Request::PointerAxis {
            axis: match axis {
                AxisArg::Vertical => Axis::Vertical,
                AxisArg::Horizontal => Axis::Horizontal,
            },
            value: *value,
        }),
        Command::Key { keycode, state } => one(Request::Key {
            keycode: *keycode,
            state: match state {
                PressArg::Press => KeyState::Pressed,
                PressArg::Release => KeyState::Released,
            },
        }),
        Command::Type { text } => one(Request::Text { text: text.clone() }),
        Command::TouchDown { slot, x, y } => one(Request::TouchDown {
            slot: *slot,
            x: *x,
            y: *y,
        }),
        Command::TouchMotion { slot, x, y } => one(Request::TouchMotion {
            slot: *slot,
            x: *x,
            y: *y,
        }),
        Command::TouchUp { slot } => one(Request::TouchUp { slot: *slot }),
        Command::TouchCancel => one(Request::TouchCancel),
        Command::ClipboardGet => one(Request::ClipboardGet),
        Command::ClipboardSet { text } => one(Request::ClipboardSet { text: text.clone() }),
        Command::Monitor { state } => one(Request::Monitor {
            on: matches!(state, OnOffArg::On),
        }),
        Command::Quit => one(Request::Quit),
    }
}

fn to_button_state(state: PressArg) -> ButtonState {
    match state {
        PressArg::Press => ButtonState::Pressed,
        PressArg::Release => ButtonState::Released,
    }
}

fn parse_region(s: &str) -> Result<Rect> {
    let parts: Vec<i32> = s
        .split(',')
        .map(|p| p.trim().parse::<i32>())
        .collect::<Result<_, _>>()
        .with_context(|| format!("invalid region {s:?}, expected X,Y,W,H"))?;
    if parts.len() != 4 {
        bail!("invalid region {s:?}, expected X,Y,W,H");
    }
    Ok(Rect {
        x: parts[0],
        y: parts[1],
        w: parts[2],
        h: parts[3],
    })
}

/// Send one request and read back one newline-delimited response.
fn round_trip(socket: &PathBuf, request: &Request) -> Result<Response> {
    let mut stream = UnixStream::connect(socket)
        .with_context(|| format!("failed to connect to {}", socket.display()))?;
    stream.write_all(request.to_line().as_bytes())?;
    stream.flush()?;

    let mut buf = Vec::new();
    let mut byte = [0u8; 4096];
    loop {
        let n = stream.read(&mut byte)?;
        if n == 0 {
            break;
        }
        buf.extend_from_slice(&byte[..n]);
        if buf.ends_with(b"\n") {
            break;
        }
    }
    let line = String::from_utf8(buf).context("response was not valid UTF-8")?;
    serde_json::from_str(line.trim_end()).context("failed to parse response")
}

fn handle_response(command: &Command, response: Response) -> Result<()> {
    match response {
        Response::Error { message } => bail!("{message}"),
        Response::Ok | Response::Pong => {
            if matches!(command, Command::Ping) {
                println!("pong");
            }
        }
        Response::Screenshot { png } => {
            if let Command::Screenshot { output, .. } = command {
                write_screenshot(output, &png.0)?;
            }
        }
        Response::Windows(windows) => {
            if windows.is_empty() {
                println!("(no windows)");
            }
            for w in windows {
                println!(
                    "{}\t{}\t{}\t{},{} {}x{}\t{}",
                    w.id.0,
                    w.app_id.as_deref().unwrap_or("-"),
                    w.title.as_deref().unwrap_or("-"),
                    w.geometry.x,
                    w.geometry.y,
                    w.geometry.w,
                    w.geometry.h,
                    if w.focused { "focused" } else { "" }
                );
            }
        }
        Response::Window(w) => {
            println!(
                "{}\t{}\t{}\t{},{} {}x{}",
                w.id.0,
                w.app_id.as_deref().unwrap_or("-"),
                w.title.as_deref().unwrap_or("-"),
                w.geometry.x,
                w.geometry.y,
                w.geometry.w,
                w.geometry.h
            );
        }
        Response::Timeout => bail!("timed out waiting for window"),
        Response::Clipboard { text } => {
            if let Some(text) = text {
                print!("{text}");
            }
        }
    }
    Ok(())
}

fn write_screenshot(output: &str, png: &[u8]) -> Result<()> {
    if output == "-" {
        std::io::stdout().write_all(png)?;
    } else {
        std::fs::write(output, png).with_context(|| format!("failed to write {output}"))?;
        eprintln!("wrote {} ({} bytes)", output, png.len());
    }
    Ok(())
}