7d2491ea10
Two changes to the unattended path, both prompted by deploying to the rack. The autostart can win the race against USB enumeration at boot, so the panel may simply not exist yet when socktop-swipe starts. Opening it now retries for ten seconds -- long enough for enumeration, short enough that a genuinely wrong device path still reports promptly. If the panel cannot be opened at all, `run` no longer treats that as fatal. By that point the dashboard is already on the wall, and exiting would replace a display you cannot swipe with no display at all. It now says loudly that gestures are inactive, points at the movement subcommands, and carries on serving the control socket. `daemon` and `doctor` still fail fast, since there is no display at stake in either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
387 lines
12 KiB
Rust
387 lines
12 KiB
Rust
//! 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,
|
|
})
|
|
}
|