Files
socktop-swipe/src/main.rs
T

387 lines
12 KiB
Rust
Raw Normal View History

//! socktop-swipe: swipe between terminal dashboards on a touchscreen.
use std::path::PathBuf;
use std::process::Command;
use std::sync::mpsc;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use socktop_swipe::config::{self, Config, Direction};
use socktop_swipe::control::{self, Ctl};
use socktop_swipe::grid::{Grid, Move};
use socktop_swipe::input::{Event, Touchpanel};
use socktop_swipe::session::tmux::Tmux;
use socktop_swipe::session::Multiplexer;
use socktop_swipe::{doctor, monitor};
#[derive(Parser)]
#[command(
name = "socktop-swipe",
version,
about = "Swipe between terminal dashboards on a touchscreen",
long_about = None
)]
struct Cli {
/// Config file. Defaults to ~/.config/socktop-swipe/config.yaml, then
/// /etc/socktop-swipe/config.yaml.
#[arg(short, long, global = true, value_name = "PATH")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Build the session, open it in a terminal and start reading the panel.
/// This is the one command to autostart.
Run {
/// Do not open the touch panel; navigate only via the control socket
/// (`socktop-swipe forward`, and so on). For a box whose panel is not
/// wired up yet, or for driving the grid from the keyboard.
#[arg(long)]
no_touch: bool,
},
/// Build the session and attach to it in this terminal. No gestures.
Attach,
/// Build the session and leave it running detached, without attaching.
/// For splitting the terminal and the gesture daemon into separate units.
Build,
/// Read the panel and drive an already-running session. For a systemd split.
Daemon {
/// Do not open the touch panel; control socket only.
#[arg(long)]
no_touch: bool,
},
/// Check the config, resolve the grid and print the map.
Validate,
/// Watch the touch panel and explain what it sees.
Doctor {
/// List candidate touch devices instead of watching one.
#[arg(long)]
list: bool,
},
/// Move deeper into the grid, as a forward swipe would.
#[command(visible_alias = "next")]
Forward,
/// Move back, as a backward swipe would.
#[command(visible_alias = "prev")]
Back,
/// Move to the row above.
Up,
/// Move to the row below.
Down,
}
fn main() {
if let Err(e) = real_main() {
eprintln!("socktop-swipe: {e:#}");
std::process::exit(1);
}
}
fn real_main() -> Result<()> {
let cli = Cli::parse();
// --list is the one command that must work before a config exists.
if let Some(Cmd::Doctor { list: true }) = cli.command {
return doctor::list();
}
let path = Config::locate(cli.config.as_deref())?;
let cfg = Config::load(&path)?;
match cli.command.unwrap_or(Cmd::Run { no_touch: false }) {
Cmd::Validate => validate(&cfg, &path),
Cmd::Doctor { .. } => doctor::run(&cfg),
Cmd::Build => {
let (tmux, grid) = build(&cfg)?;
tmux.build(&grid)?;
println!(
"session {:?} is up. Attach with: tmux attach -t {}",
cfg.session, cfg.session
);
Ok(())
}
Cmd::Attach => {
let (tmux, grid) = build(&cfg)?;
tmux.build(&grid)?;
tmux.attach()?;
unreachable!()
}
Cmd::Daemon { no_touch } => daemon(&cfg, no_touch),
Cmd::Run { no_touch } => run(&cfg, no_touch),
Cmd::Forward => step(&cfg, "forward"),
Cmd::Back => step(&cfg, "back"),
Cmd::Up => step(&cfg, "up"),
Cmd::Down => step(&cfg, "down"),
}
}
fn step(cfg: &Config, direction: &str) -> Result<()> {
println!("{}", control::send(&cfg.session, direction)?);
Ok(())
}
fn build(cfg: &Config) -> Result<(Tmux, Grid)> {
let grid = Grid::new(monitor::build_cells(cfg)?)?;
Ok((Tmux::new(&cfg.session, cfg.indicator), grid))
}
fn validate(cfg: &Config, path: &std::path::Path) -> Result<()> {
let grid = Grid::new(monitor::build_cells(cfg)?)?;
println!("{}\n", path.display());
let mut missing = Vec::new();
for row in &grid.rows {
println!("row {}:", row.row);
for cell in &row.cells {
let start = if cell.coord == config::Coord::new(0, 0) {
" <- start"
} else {
""
};
println!(
" {:<7} {:<20} {} screen{}{start}",
cell.coord.to_string(),
cell.kind.to_string(),
cell.screens(),
if cell.screens() == 1 { "" } else { "s" },
);
for pane in &cell.panes {
let bin = &pane.command[0];
let ok = which(bin);
if !ok {
missing.push(bin.clone());
}
println!(
" {} {}",
if ok { "\u{2713}" } else { "\u{2717}" },
shell_words::join(pane.command.iter().map(String::as_str))
);
}
}
println!();
}
if grid.cells().all(|c| c.coord != config::Coord::new(0, 0)) {
println!(
"note: no screen at 0x0, so the display starts at {} instead.\n",
grid.cell().coord
);
}
if !std::path::Path::new(&cfg.touch.device).exists() {
println!(
"touch: \u{2717} {} is not present right now",
cfg.touch.device
);
println!(" `socktop-swipe doctor --list` shows what is.\n");
} else {
println!("touch: \u{2713} {}\n", cfg.touch.device);
}
if missing.is_empty() {
println!("Config is valid.");
} else {
missing.sort();
missing.dedup();
println!("Config parses, but these are not installed or not on PATH:");
for m in &missing {
println!(" {m}");
}
println!("\nSet full paths under `binaries:` if they are installed elsewhere.");
}
Ok(())
}
fn which(bin: &str) -> bool {
if bin.contains('/') {
return std::path::Path::new(bin).is_file();
}
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|dir| dir.join(bin).is_file()))
.unwrap_or(false)
}
/// Build the session, hand it to a terminal, and read the panel until that
/// terminal exits. One process, so a second copy cannot fight the first over
/// the input device -- the evdev grab fails immediately and says so.
fn run(cfg: &Config, no_touch: bool) -> Result<()> {
let (tmux, mut grid) = build(cfg)?;
tmux.build(&grid)?;
let (tx, rx) = mpsc::channel();
let _socket = control::listen(&cfg.session, tx.clone())?;
let _guard = control::SocketGuard(control::socket_path(&cfg.session));
let attach = tmux.attach_argv();
let mut child = match &cfg.terminal {
Some(term) => {
let mut argv = shell_words::split(term)
.with_context(|| format!("cannot parse terminal: {term}"))?;
// Same tilde expansion as `binaries:`. A window manager's PATH
// rarely includes ~/.cargo/bin, so a full path is the usual answer
// here and it should not have to be spelled out longhand.
let prog = config::expand_tilde(&argv.remove(0));
Command::new(&prog)
.args(argv)
.arg("-e")
.args(&attach)
.spawn()
.with_context(|| format!("cannot start terminal {prog:?}"))?
}
None => Command::new(&attach[0])
.args(&attach[1..])
.spawn()
.context("cannot attach to the session")?,
};
// Closing the dashboard window is the ordinary way this ends.
let quit = tx.clone();
std::thread::spawn(move || {
let _ = child.wait();
let _ = quit.send(Ctl::Quit);
});
if !no_touch {
// Deliberately not fatal. The dashboard is already on the wall by this
// point; exiting because the panel is missing would replace a display
// you cannot swipe with no display at all. Say so loudly and carry on
// serving the control socket, which is still a way to drive it.
if let Err(e) = spawn_panel(cfg, tx) {
eprintln!("socktop-swipe: touch gestures are NOT active: {e:#}");
eprintln!(
"socktop-swipe: the dashboard is up; drive it with `socktop-swipe forward` etc."
);
}
}
drive(&tmux, &mut grid, rx)
}
fn daemon(cfg: &Config, no_touch: bool) -> Result<()> {
let (tmux, mut grid) = build(cfg)?;
if !tmux.is_running() {
anyhow::bail!(
"no session named {:?}. Start one with `socktop-swipe attach`, \
or use `socktop-swipe run` to do both.",
cfg.session
);
}
tmux.adopt(&grid)?;
let (tx, rx) = mpsc::channel();
let _socket = control::listen(&cfg.session, tx.clone())?;
let _guard = control::SocketGuard(control::socket_path(&cfg.session));
if !no_touch {
spawn_panel(cfg, tx)?;
} else {
// Without a panel there is nothing else to hold the channel open.
drop(tx);
}
drive(&tmux, &mut grid, rx)
}
/// Open the touch panel, retrying briefly.
///
/// At boot the autostart can win the race against USB enumeration, so the
/// device is simply not there yet. Ten seconds covers that without making a
/// genuinely wrong device path take ten seconds to report.
fn open_panel(cfg: &Config) -> Result<Touchpanel> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
match Touchpanel::open(&cfg.touch) {
Ok(p) => return Ok(p),
Err(e) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = e;
}
Err(e) => return Err(e),
}
}
}
/// Read the panel on its own thread. Opening it here rather than in the thread
/// keeps a permission or grab failure on the main path, where it can be
/// reported properly instead of vanishing into a detached thread.
fn spawn_panel(cfg: &Config, tx: mpsc::Sender<Ctl>) -> Result<()> {
let mut panel = open_panel(cfg)?;
let gestures = cfg.gestures.clone();
std::thread::spawn(move || {
let result = panel.run(|ev| {
let Event::Swipe(s) = ev else { return true };
match binding(&gestures, s.direction) {
Some(m) => tx.send(Ctl::Go(m, None)).is_ok(),
None => true,
}
});
if let Err(e) = result {
let _ = tx.send(Ctl::Failed(e));
}
});
Ok(())
}
fn drive(tmux: &Tmux, grid: &mut Grid, rx: mpsc::Receiver<Ctl>) -> Result<()> {
for msg in rx {
match msg {
Ctl::Quit => return Ok(()),
Ctl::Failed(e) => return Err(e),
Ctl::Go(m, reply) => {
let pos = grid.apply(m);
let outcome = if pos.changed {
tmux.show(grid, &pos)
} else {
Ok(())
};
if let Err(e) = outcome {
// A vanished session is the normal way this ends: the user
// closed the terminal. Anything else is worth surfacing.
if !tmux.is_running() {
return Ok(());
}
answer(reply, &format!("error: {e:#}"));
return Err(e);
}
let edge = if pos.changed {
""
} else {
" (edge, nothing to move to)"
};
answer(
reply,
&format!("{} {}{edge}", pos.coord, grid.cell().screen_label()),
);
}
}
}
Ok(())
}
/// Tell a waiting movement subcommand what happened. Best effort: it may have
/// been interrupted, and a gesture carries no stream at all.
fn answer(reply: Option<std::os::unix::net::UnixStream>, text: &str) {
use std::io::Write;
if let Some(mut r) = reply {
let _ = writeln!(r, "{text}");
}
}
fn binding(g: &config::Gestures, d: Direction) -> Option<Move> {
Some(match d {
_ if d == g.forward => Move::Forward,
_ if d == g.back => Move::Back,
_ if d == g.up => Move::Up,
_ if d == g.down => Move::Down,
_ => return None,
})
}