mod app;
mod clip;
mod pages;
mod web;
use std::io::{BufRead, IsTerminal, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::Duration;
use crate::owner::Owner;
use crate::{FileStateStore, KeyStore};
use anyhow::{Context as _, bail};
use crate::cli::branding::Branding;
use crate::cli::config::Home;
use crate::cli::context::{CliEvents, Context, Output, StdOutput};
use crate::cli::keystore::{CredentialsFile, LazyKeyStore};
use crate::cli::style::{Paint, Role};
pub use clip::Clipboard;
#[derive(Debug, Clone, Copy)]
pub struct Timings {
pub code_lapse: Duration,
pub idle: Duration,
pub confirm_lapse: Duration,
pub clipboard_clear: Duration,
pub reveal: Duration,
}
impl Default for Timings {
fn default() -> Timings {
Timings {
code_lapse: Duration::from_secs(60),
idle: Duration::from_secs(15 * 60),
confirm_lapse: Duration::from_secs(120),
clipboard_clear: Duration::from_secs(30),
reveal: Duration::from_secs(10),
}
}
}
pub struct UiOptions {
pub home: Option<PathBuf>,
pub file_store: bool,
pub clipboard: Clipboard,
pub timings: Timings,
}
impl Default for UiOptions {
fn default() -> UiOptions {
UiOptions {
home: None,
file_store: false,
clipboard: Clipboard::Detect,
timings: Timings::default(),
}
}
}
pub struct UiHandle {
pub origin: String,
pub terminal: mpsc::Sender<String>,
pub feed: mpsc::Receiver<String>,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl UiHandle {
pub fn stop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl Drop for UiHandle {
fn drop(&mut self) {
self.stop();
}
}
fn ui_owner(owner: Owner, output: Arc<dyn Output>, branding: Branding) -> Owner {
owner.with_events(Arc::new(CliEvents::new(output, branding, false, false)))
}
pub fn start(opts: UiOptions) -> anyhow::Result<UiHandle> {
let branding = Branding::GV;
let home = match opts.home {
Some(dir) => Home { dir },
None => Home::locate(&branding)?,
};
let output: Arc<dyn Output> = Arc::new(StdOutput);
let keys: Arc<dyn KeyStore> = if opts.file_store {
Arc::new(CredentialsFile::new(home.credentials_path()))
} else {
Arc::new(LazyKeyStore::new(home.clone(), branding, output.clone()))
};
let owner = Owner::open(keys, Arc::new(FileStateStore::new(&home.dir)))?;
start_owner(
ui_owner(owner, output, branding),
branding,
opts.clipboard,
opts.timings,
)
}
fn start_owner(
owner: Owner,
branding: Branding,
clipboard: Clipboard,
timings: Timings,
) -> anyhow::Result<UiHandle> {
let stop = Arc::new(AtomicBool::new(false));
let (term_tx, term_rx) = mpsc::channel();
let (feed_tx, feed_rx) = mpsc::channel();
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
let flag = stop.clone();
let thread =
std::thread::Builder::new()
.name("gv-ui".into())
.spawn(move || {
match app::App::new(owner, branding, clipboard, timings, term_rx, feed_tx, flag) {
Ok(app) => {
let _ = ready_tx.send(Ok(app.origin().to_owned()));
app.run();
}
Err(e) => {
let _ = ready_tx.send(Err(e));
}
}
})?;
let origin = ready_rx
.recv()
.with_context(|| format!("{} ui stopped while starting", branding.bin_name))??;
Ok(UiHandle {
origin,
terminal: term_tx,
feed: feed_rx,
stop,
thread: Some(thread),
})
}
pub fn run_cli(ctx: &Context, open_browser: bool) -> anyhow::Result<()> {
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
bail!(
"{} ui needs a terminal on stdin and stdout: that is where you confirm tokens, \
revocations and deletions",
ctx.branding().bin_name
);
}
let owner = ui_owner(ctx.owner()?, ctx.output().clone(), *ctx.branding());
let mut ui = start_owner(
owner,
*ctx.branding(),
Clipboard::Detect,
Timings::default(),
)?;
signal_hook::flag::register(signal_hook::consts::SIGINT, ui.stop.clone())
.with_context(|| format!("{} ui could not catch Ctrl-C", ctx.branding().bin_name))?;
let lines = ui.terminal.clone();
let stop = ui.stop.clone();
std::thread::spawn(move || {
for line in std::io::stdin().lock().lines() {
let Ok(line) = line else { break };
if lines.send(line).is_err() {
return;
}
}
stop.store(true, Ordering::SeqCst);
});
let mut opened = !open_browser;
let mut out = std::io::stdout();
let paint = Paint::for_terminal(true);
let bin = ctx.branding().bin_name;
for line in ui.feed.iter() {
let _ = writeln!(out, "{}", paint_feed(paint, bin, &line));
let _ = out.flush();
if !opened && let Some(url) = link_in(&line) {
opened = true;
if open(url).is_err() {
let _ = writeln!(out, " (could not open a browser: open the link yourself)");
}
}
}
ui.stop();
Ok(())
}
fn paint_feed(paint: Paint, bin: &str, line: &str) -> String {
if paint == Paint::PLAIN {
return line.to_owned();
}
let (stamp, rest) = match line.split_at_checked(10) {
Some((s, r)) if is_clock(s) => (paint.apply(Role::Muted, &s[..8]) + " ", r),
_ => (String::new(), line),
};
let body = if line.starts_with(&format!("{bin} ui ")) {
paint.apply(Role::Bold, rest)
} else if let Some(i) = rest.find("└─ done:") {
let (head, tail) = rest.split_at(i + "└─ ".len());
format!("{head}{}", paint.apply(Role::Ok, tail))
} else if let Some(i) = rest.find("└─ ").filter(|_| {
rest.contains("failed:") || rest.contains("not confirmed") || rest.contains("no answer")
}) {
let (head, tail) = rest.split_at(i + "└─ ".len());
format!("{head}{}", paint.apply(Role::Warn, tail))
} else if let Some(i) = rest.find("code ") {
let at = i + "code ".len();
let code_len = rest[at..].find(' ').unwrap_or(rest.len() - at);
format!(
"{}{}{}",
&rest[..at],
paint.apply(Role::Accent, &rest[at..at + code_len]),
&rest[at + code_len..]
)
} else if let Some(url) = rest.split_whitespace().find(|w| w.starts_with("http")) {
rest.replacen(url, &paint.apply(Role::Accent, url), 1)
} else {
rest.to_owned()
};
stamp + &body
}
fn is_clock(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 10
&& b[2] == b':'
&& b[5] == b':'
&& &b[8..] == b" "
&& [0, 1, 3, 4, 6, 7].iter().all(|&i| b[i].is_ascii_digit())
}
fn link_in(line: &str) -> Option<&str> {
let start = line.find("http://127.0.0.1:")?;
let link = line[start..].split_whitespace().next()?;
link.contains("/#c=").then_some(link)
}
fn open(url: &str) -> std::io::Result<()> {
let program = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
Command::new(program)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_the_link() {
assert_eq!(
link_in(" page http://127.0.0.1:5000/#c=abc "),
Some("http://127.0.0.1:5000/#c=abc")
);
assert_eq!(link_in(" server http://127.0.0.1:8750"), None);
}
#[test]
fn paints_the_feed_by_role() {
let on = Paint::for_terminal(true);
if on == Paint::PLAIN {
assert_eq!(
paint_feed(on, "gv", "14:02:11 copy x"),
"14:02:11 copy x"
);
return;
}
assert_eq!(
paint_feed(on, "gv", "14:02:11 session acme/prod opened"),
"\x1b[2m14:02:11\x1b[0m session acme/prod opened"
);
assert_eq!(
paint_feed(on, "gv", " page http://127.0.0.1:5000/#c=abc"),
" page \x1b[36mhttp://127.0.0.1:5000/#c=abc\x1b[0m"
);
assert_eq!(
paint_feed(
on,
"gv",
" │ code 4813 the browser must show the same code"
),
" │ code \x1b[36m4813\x1b[0m the browser must show the same code"
);
assert_eq!(
paint_feed(on, "gv", " └─ done: minted read token 7c1e"),
" └─ \x1b[32mdone: minted read token 7c1e\x1b[0m"
);
assert_eq!(
paint_feed(on, "gv", " └─ not confirmed; nothing was changed"),
" └─ \x1b[33mnot confirmed; nothing was changed\x1b[0m"
);
assert_eq!(
paint_feed(on, "gv", "gv ui · galata-vault 0.1.0"),
"\x1b[1mgv ui · galata-vault 0.1.0\x1b[0m"
);
assert_eq!(
paint_feed(Paint::PLAIN, "gv", " └─ done: x"),
" └─ done: x"
);
}
}