onionwire/src/tui.rs

301 lines
9.8 KiB
Rust
Raw Normal View History

use std::io;
use rand::RngCore;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame};
use crate::qr::{self, QrPayload};
use crate::store::{Friend, Store};
pub fn run(store: Store) -> Result<(), String> {
let mut app = App::new(store)?;
let mut terminal = ratatui::init();
let result = app.run(&mut terminal);
ratatui::restore();
result
}
struct App {
store: Store,
screen: Screen,
friends: Vec<Friend>,
selected: usize,
me_fp: String,
me_onion: String,
identity_sk: Vec<u8>,
}
enum Screen {
Main,
Share { art: String, payload: String },
Paste { buf: String, err: Option<String> },
Approve { payload: QrPayload },
Rotate,
}
impl App {
fn new(store: Store) -> Result<Self, String> {
let me = store.self_identity().map_err(|e| e.to_string())?;
let friends = store.list_friends().map_err(|e| e.to_string())?;
Ok(Self {
store,
screen: Screen::Main,
friends,
selected: 0,
me_fp: to_hex(&me.identity_pk),
me_onion: me.onion,
identity_sk: me.identity_sk,
})
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<(), String> {
loop {
terminal.draw(|f| self.draw(f)).map_err(|e| e.to_string())?;
let Event::Key(key) = event::read().map_err(io_err)? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
self.handle_key(key)?;
}
}
fn handle_key(&mut self, key: KeyEvent) -> Result<(), String> {
match &mut self.screen {
Screen::Main => match key.code {
KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
}
KeyCode::Down => {
if !self.friends.is_empty() {
self.selected = (self.selected + 1).min(self.friends.len() - 1);
}
}
KeyCode::F(2) => self.open_share()?,
KeyCode::F(3) => {
self.screen = Screen::Paste {
buf: String::new(),
err: None,
};
}
KeyCode::F(4) => self.screen = Screen::Rotate,
_ => {}
},
Screen::Share { .. } => {
if key.code == KeyCode::Esc {
self.screen = Screen::Main;
}
}
Screen::Paste { buf, err } => match key.code {
KeyCode::Esc => self.screen = Screen::Main,
KeyCode::Enter => {
let raw = buf.clone();
self.submit_paste(&raw)?;
}
KeyCode::Backspace => {
buf.pop();
*err = None;
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
buf.push(c);
*err = None;
}
_ => {}
},
Screen::Approve { payload } => match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
let p = payload.clone();
self.accept_friend(&p)?;
}
KeyCode::Char('n') | KeyCode::Esc => self.screen = Screen::Main,
_ => {}
},
Screen::Rotate => {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
self.screen = Screen::Main;
}
}
}
Ok(())
}
fn open_share(&mut self) -> Result<(), String> {
let mut spk = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut spk);
let onion = if self.me_onion.is_empty() {
"unset"
} else {
self.me_onion.as_str()
};
let payload = qr::encode(&self.identity_sk, onion, &spk).map_err(|e| e.to_string())?;
let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?;
self.screen = Screen::Share { art, payload };
Ok(())
}
fn submit_paste(&mut self, raw: &str) -> Result<(), String> {
match qr::decode(raw.trim()) {
Err(e) => {
self.screen = Screen::Paste {
buf: raw.to_string(),
err: Some(e.to_string()),
};
}
Ok(payload) => {
let known = self
.store
.get_friend(&payload.pubkey)
.map_err(|e| e.to_string())?
.is_some();
if known {
self.accept_friend(&payload)?;
} else {
self.screen = Screen::Approve { payload };
}
}
}
Ok(())
}
fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> {
self.store
.upsert_friend(&payload.pubkey, &payload.onion, None)
.map_err(|e| e.to_string())?;
self.reload_friends()?;
self.screen = Screen::Main;
Ok(())
}
fn reload_friends(&mut self) -> Result<(), String> {
self.friends = self.store.list_friends().map_err(|e| e.to_string())?;
if self.friends.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.friends.len() - 1);
}
Ok(())
}
fn draw(&self, f: &mut Frame) {
match &self.screen {
Screen::Main => self.draw_main(f),
Screen::Share { art, payload } => draw_share(f, art, payload),
Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()),
Screen::Approve { payload } => draw_approve(f, payload),
Screen::Rotate => draw_rotate(f),
}
}
fn draw_main(&self, f: &mut Frame) {
let area = f.area();
let cols = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area);
let panes =
Layout::horizontal([Constraint::Percentage(36), Constraint::Min(10)]).split(cols[0]);
let items: Vec<ListItem> = self
.friends
.iter()
.enumerate()
.map(|(i, friend)| {
let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint);
let fp = truncate_fp(&friend.fingerprint);
let stale = if friend.last_connect_ok { "" } else { " stale" };
let mark = if i == self.selected { ">" } else { " " };
ListItem::new(format!("{mark} {name} {fp}{stale}"))
})
.collect();
let roster = List::new(items).block(Block::default().borders(Borders::ALL).title("roster"));
f.render_widget(roster, panes[0]);
let chat_text = if let Some(friend) = self.friends.get(self.selected) {
let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint);
format!("{name}\n\nchat arrives in M3")
} else {
"no friends yet\nF3 paste a QR to add one".into()
};
let chat = Paragraph::new(chat_text)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("chat"));
f.render_widget(chat, panes[1]);
let onion = if self.me_onion.is_empty() {
"unset"
} else {
self.me_onion.as_str()
};
let status = format!(
"TOR: offline | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit",
truncate_fp(&self.me_fp),
onion
);
f.render_widget(Paragraph::new(status), cols[1]);
}
}
fn draw_share(f: &mut Frame, art: &str, payload: &str) {
let body = format!("{art}\n{payload}\n\nEsc back");
f.render_widget(
Paragraph::new(body)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("F2 share")),
f.area(),
);
}
fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) {
let hint = err.unwrap_or("paste onionwire:v1 payload, Enter to submit");
let body = format!("{hint}\n\n{buf}");
f.render_widget(
Paragraph::new(body)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("F3 paste QR")),
f.area(),
);
}
fn draw_approve(f: &mut Frame, payload: &QrPayload) {
let fp = to_hex(&payload.pubkey);
let body = format!(
"Unknown key. Add this friend?\n\nfp {}\nonion {}\n\ny accept n / Esc reject",
truncate_fp(&fp),
payload.onion
);
f.render_widget(
Paragraph::new(body).block(Block::default().borders(Borders::ALL).title("approve")),
f.area(),
);
}
fn draw_rotate(f: &mut Frame) {
let body = "Rotate onion address?\n\
Your identity key stays the same.\n\
Online friends get a signed location update.\n\
Offline friends CANNOT find you until they rescan your QR.\n\n\
M4 will rotate the HS. Esc / Enter to go back (no change).";
f.render_widget(
Paragraph::new(body)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("F4 rotate")),
f.area(),
);
}
fn truncate_fp(fp: &str) -> String {
let s: String = fp.chars().take(8).collect();
format!("{s}")
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn io_err(e: io::Error) -> String {
e.to_string()
}