diff options
| author | Andrew Guschin <guschin@altlinux.org> | 2026-10-30 03:10:41 +0400 |
|---|---|---|
| committer | Andrew Guschin <guschin@altlinux.org> | 2026-10-30 03:10:41 +0400 |
| commit | d608ffeeda1a479c9dc0e5f6ad269ba07a510d7f (patch) | |
| tree | 3824ec9da5ca0adac913cc47e3e20054c3dc843a /src/tui.rs | |
| parent | 40cf3276cdc74bf6f1795990ed600891a637d1fe (diff) | |
Implement Linux proxy controller, subscription state, and CLI/TUI
Diffstat (limited to 'src/tui.rs')
| -rw-r--r-- | src/tui.rs | 565 |
1 files changed, 565 insertions, 0 deletions
diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..9cb69a7 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,565 @@ +use crate::{ + bus::Client, + controller::Operation, + model::{ConfigurationSummary, Settings, Status, SubscriptionSummary}, +}; +use anyhow::{Result, ensure}; +use crossterm::{ + event::{self, Event, KeyCode, KeyModifiers}, + execute, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + Terminal, + backend::CrosstermBackend, + layout::{Constraint, Layout}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Tabs}, +}; +use std::{ + io::{self, IsTerminal}, + time::Duration, +}; + +type Screen = Terminal<CrosstermBackend<io::Stdout>>; +struct Restore; +impl Drop for Restore { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + } +} + +struct Ui { + tab: usize, + row: usize, + viewed: Option<String>, + filter: String, + editing_filter: bool, + chooser: bool, + chooser_query: String, + chooser_row: usize, + console: bool, + details: bool, + help: bool, + first_g: bool, + status: Status, + subscriptions: Vec<SubscriptionSummary>, + entries: Vec<ConfigurationSummary>, + settings: Settings, + message: String, + logs: Vec<String>, +} + +impl Ui { + async fn refresh(&mut self, client: &Client) -> Result<()> { + self.status = client.read("Status").await?; + self.subscriptions = client.read("Subscriptions").await?; + if !self + .subscriptions + .iter() + .any(|s| Some(&s.name) == self.viewed.as_ref()) + { + self.viewed = self.subscriptions.first().map(|s| s.name.clone()); + } + self.entries = client.configurations(self.viewed.as_deref()).await?; + self.settings = client.read("Settings").await?; + self.logs = client.read("Logs").await?; + self.row = self.row.min(self.visible().len().saturating_sub(1)); + Ok(()) + } + fn visible(&self) -> Vec<&ConfigurationSummary> { + let query = self.filter.to_lowercase(); + self.entries + .iter() + .filter(|e| { + e.name.to_lowercase().contains(&query) + || e.host + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&query) + }) + .collect() + } + fn choices(&self) -> Vec<&SubscriptionSummary> { + self.subscriptions + .iter() + .filter(|s| fuzzy(&self.chooser_query, &s.name)) + .collect() + } + fn selected_row(&self) -> Option<&ConfigurationSummary> { + self.visible().get(self.row).copied() + } +} + +pub async fn run(client: Client) -> Result<()> { + ensure!( + io::stdin().is_terminal() && io::stdout().is_terminal(), + "The TUI needs a terminal; use status --json for scripts" + ); + let status = client.read("Status").await?; + let mut ui = Ui { + tab: 0, + row: 0, + viewed: None, + filter: String::new(), + editing_filter: false, + chooser: false, + chooser_query: String::new(), + chooser_row: 0, + console: false, + details: false, + help: false, + first_g: false, + status, + subscriptions: vec![], + entries: vec![], + settings: Settings::default(), + message: String::new(), + logs: vec![], + }; + ui.refresh(&client).await?; + terminal::enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + let _restore = Restore; + let mut screen = Terminal::new(CrosstermBackend::new(io::stdout()))?; + let mut last_refresh = std::time::Instant::now(); + loop { + draw(&mut screen, &ui)?; + if !event::poll(Duration::from_millis(200))? { + if last_refresh.elapsed() >= Duration::from_secs(2) { + if let Err(error) = ui.refresh(&client).await { + ui.message = error.to_string(); + } + last_refresh = std::time::Instant::now(); + } + continue; + } + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind == event::KeyEventKind::Release { + continue; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + break; + } + if ui.chooser { + match key.code { + KeyCode::Esc => ui.chooser = false, + KeyCode::Enter => { + if let Some(name) = ui.choices().get(ui.chooser_row).map(|s| s.name.clone()) { + ui.viewed = Some(name); + ui.filter.clear(); + ui.row = 0; + } + ui.chooser = false; + ui.refresh(&client).await?; + } + KeyCode::Down => { + ui.chooser_row = (ui.chooser_row + 1).min(ui.choices().len().saturating_sub(1)) + } + KeyCode::Up => ui.chooser_row = ui.chooser_row.saturating_sub(1), + KeyCode::Char('n' | 'j') if key.modifiers.contains(KeyModifiers::CONTROL) => { + ui.chooser_row = (ui.chooser_row + 1).min(ui.choices().len().saturating_sub(1)) + } + KeyCode::Char('p' | 'k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + ui.chooser_row = ui.chooser_row.saturating_sub(1) + } + KeyCode::Backspace => { + ui.chooser_query.pop(); + ui.chooser_row = 0; + } + KeyCode::Char(c) => { + ui.chooser_query.push(c); + ui.chooser_row = 0; + } + _ => {} + } + continue; + } + if ui.editing_filter { + match key.code { + KeyCode::Esc => { + ui.filter.clear(); + ui.editing_filter = false; + } + KeyCode::Enter => ui.editing_filter = false, + KeyCode::Backspace => { + ui.filter.pop(); + } + KeyCode::Char(c) => ui.filter.push(c), + _ => {} + } + ui.row = 0; + continue; + } + if ui.details || ui.help { + if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) { + ui.details = false; + ui.help = false; + } + continue; + } + let mut operation = None; + match key.code { + KeyCode::Char('q') => break, + KeyCode::Esc => { + ui.tab = 0; + ui.filter.clear(); + } + KeyCode::Char('h') | KeyCode::Left => { + ui.tab = (ui.tab + 2) % 3; + ui.row = 0; + } + KeyCode::Char('l') | KeyCode::Right => { + ui.tab = (ui.tab + 1) % 3; + ui.row = 0; + } + KeyCode::Char('j') | KeyCode::Down => ui.row = ui.row.saturating_add(1), + KeyCode::Char('k') | KeyCode::Up => ui.row = ui.row.saturating_sub(1), + KeyCode::Char('g') if ui.first_g => { + ui.row = 0; + ui.first_g = false; + } + KeyCode::Char('g') => { + ui.first_g = true; + continue; + } + KeyCode::Char('G') => { + ui.row = if ui.tab == 1 { + ui.subscriptions.len() + } else { + ui.visible().len() + } + .saturating_sub(1) + } + KeyCode::Char('/') if ui.tab == 0 => { + ui.editing_filter = true; + } + KeyCode::Char('s') => { + ui.chooser = true; + ui.chooser_query.clear(); + ui.chooser_row = 0; + } + KeyCode::Char('J' | 'K') => { + if !ui.subscriptions.is_empty() { + let index = ui + .subscriptions + .iter() + .position(|s| Some(&s.name) == ui.viewed.as_ref()) + .unwrap_or(0); + let next = if key.code == KeyCode::Char('J') { + (index + 1) % ui.subscriptions.len() + } else { + (index + ui.subscriptions.len() - 1) % ui.subscriptions.len() + }; + ui.viewed = Some(ui.subscriptions[next].name.clone()); + ui.filter.clear(); + ui.row = 0; + ui.refresh(&client).await?; + } + } + KeyCode::Char('c') => ui.console = !ui.console, + KeyCode::Char('?') => ui.help = true, + KeyCode::Char('d') if ui.tab == 0 => ui.details = true, + KeyCode::Char(' ') => { + operation = Some(if ui.status.requested_enabled { + Operation::Disable + } else { + Operation::Enable + }) + } + KeyCode::Char('x') if ui.tab == 0 => operation = Some(Operation::Unset), + KeyCode::Char('u') => { + operation = Some(Operation::Refresh { + name: if ui.tab == 1 { + ui.subscriptions.get(ui.row).map(|s| s.name.clone()) + } else { + ui.viewed.clone() + }, + }) + } + KeyCode::Enter if ui.tab == 0 => { + if let Some(entry) = ui.selected_row() { + operation = Some(Operation::Select { + id: entry.id.clone(), + }); + } + } + KeyCode::Enter if ui.tab == 1 => { + if let Some(s) = ui.subscriptions.get(ui.row) { + ui.viewed = Some(s.name.clone()); + ui.filter.clear(); + ui.row = 0; + ui.tab = 0; + ui.refresh(&client).await?; + } + } + KeyCode::Char('a') if ui.tab == 1 => { + match form( + &mut screen, + "Add subscription", + &["Name", "URL"], + &[String::new(), String::new()], + )? { + Some(values) => { + operation = Some(Operation::AddSubscription { + name: values[0].clone(), + url: values[1].clone(), + }) + } + None => {} + } + } + KeyCode::Char('e') if ui.tab == 1 => { + if let Some(s) = ui.subscriptions.get(ui.row) { + if let Some(values) = form( + &mut screen, + "Edit subscription URL", + &["URL"], + &[String::new()], + )? { + operation = Some(Operation::EditSubscription { + name: s.name.clone(), + url: values[0].clone(), + }); + } + } + } + KeyCode::Char('x') if ui.tab == 1 => { + if let Some(s) = ui.subscriptions.get(ui.row) { + if let Some(values) = form( + &mut screen, + "Type delete to remove this subscription", + &["Confirmation"], + &[String::new()], + )? { + if values[0] == "delete" { + operation = Some(Operation::DeleteSubscription { + name: s.name.clone(), + }); + } + } + } + } + KeyCode::Char('e') | KeyCode::Enter if ui.tab == 2 => { + let current = [ + &ui.settings.ping_url, + &ui.settings.speed_url, + &ui.settings.maximum_download_mib.to_string(), + &ui.settings.maximum_duration_seconds.to_string(), + ] + .map(|v| v.to_string()); + if let Some(values) = form( + &mut screen, + "Settings", + &[ + "HTTPS Ping URL", + "Speed URL", + "Maximum MiB", + "Maximum seconds", + ], + ¤t, + )? { + let parsed = (values[2].parse::<u64>(), values[3].parse::<u64>()); + match parsed { + (Ok(bytes), Ok(seconds)) => { + operation = Some(Operation::SaveSettings { + settings: Settings { + ping_url: values[0].clone(), + speed_url: values[1].clone(), + maximum_download_mib: bytes, + maximum_duration_seconds: seconds, + }, + }) + } + _ => ui.message = "Limits must be positive integers".into(), + } + } + } + KeyCode::Char('e') => { + suspend(&mut screen)?; + let result = crate::editor::open(&ui.logs); + resume(&mut screen)?; + if let Err(error) = result { + ui.message = error.to_string(); + } + } + KeyCode::Char('t' | 'b') if ui.tab == 0 => { + if let Some(entry) = ui.selected_row() { + let id = entry.id.clone(); + suspend(&mut screen)?; + let result = async { + let _agent = crate::authentication::TerminalAgent::start(true)?; + client + .diagnostic(Some(&id), key.code == KeyCode::Char('b'), true) + .await + } + .await; + resume(&mut screen)?; + ui.message = match result { + Ok(value) => value, + Err(error) => error.to_string(), + }; + } + } + _ => {} + } + ui.first_g = false; + if let Some(operation) = operation { + suspend(&mut screen)?; + let result = async { + let _agent = crate::authentication::TerminalAgent::start(true)?; + client.change(operation, true).await + } + .await; + resume(&mut screen)?; + ui.message = match result { + Ok(()) => "Applied".into(), + Err(error) => error.to_string(), + }; + ui.refresh(&client).await?; + } + ui.row = ui.row.min( + if ui.tab == 1 { + ui.subscriptions.len() + } else { + ui.visible().len() + } + .saturating_sub(1), + ); + } + Ok(()) +} + +fn suspend(screen: &mut Screen) -> Result<()> { + terminal::disable_raw_mode()?; + execute!(screen.backend_mut(), LeaveAlternateScreen)?; + Ok(()) +} +fn resume(screen: &mut Screen) -> Result<()> { + terminal::enable_raw_mode()?; + execute!(screen.backend_mut(), EnterAlternateScreen)?; + screen.clear()?; + Ok(()) +} + +fn form( + screen: &mut Screen, + title: &str, + labels: &[&str], + initial: &[String], +) -> Result<Option<Vec<String>>> { + let mut values = initial.to_vec(); + let mut field = 0; + let mut discarding = false; + loop { + screen.draw(|frame| { + let text = labels + .iter() + .enumerate() + .map(|(i, label)| { + let value = if *label == "URL" { + "*".repeat(values[i].chars().count()) + } else { + values[i].clone() + }; + format!("{} {label}: {value}", if field == i { ">" } else { " " }) + }) + .collect::<Vec<_>>() + .join("\n\n"); + let help = if discarding { + "Discard edits? y confirms, any other key resumes" + } else { + "Tab moves fields. Enter saves from the last field. Esc cancels." + }; + frame.render_widget( + Paragraph::new(format!("{text}\n\n{help}")) + .block(Block::default().borders(Borders::ALL).title(title)), + frame.area(), + ); + })?; + let Event::Key(key) = event::read()? else { + continue; + }; + if discarding { + if key.code == KeyCode::Char('y') { + return Ok(None); + } + discarding = false; + continue; + } + match key.code { + KeyCode::Esc => { + if values == initial { + return Ok(None); + } + discarding = true; + } + KeyCode::Tab => field = (field + 1) % values.len(), + KeyCode::BackTab => field = (field + values.len() - 1) % values.len(), + KeyCode::Enter if field + 1 == values.len() => return Ok(Some(values)), + KeyCode::Enter => field += 1, + KeyCode::Backspace => { + values[field].pop(); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if values[field].len() < 16384 { + values[field].push(c); + } + } + _ => {} + } + } +} + +fn draw(screen: &mut Screen, ui: &Ui) -> Result<()> { + screen.draw(|frame| { + let layout = Layout::vertical([Constraint::Length(1), Constraint::Length(2), Constraint::Min(4), Constraint::Length(if ui.console { 6 } else { 0 }), Constraint::Length(2)]).split(frame.area()); + frame.render_widget(Tabs::new(["Connections", "Subscriptions", "Settings"]).select(ui.tab).highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), layout[0]); + let actions = match ui.tab { + 0 => format!("{} [s] Update [u] Details [d] Ping [t] Speed [b] {}", ui.viewed.as_deref().unwrap_or("No subscription"), if ui.filter.is_empty() && !ui.editing_filter { String::new() } else { format!("/ {}", ui.filter) }), + 1 => "Add [a] Edit [e] Update [u] Delete [x] Open [Enter]".into(), + _ => "Edit settings [e]".into(), + }; + frame.render_widget(Paragraph::new(actions), layout[1]); + if ui.help { + frame.render_widget(Paragraph::new("h/l: screens j/k: rows gg/G: first/last\nEnter: select/open Space: enable/disable x: unset/delete\ns: subscription chooser J/K: cycle subscriptions /: filter\nd: details t: Ping b: Speed c: console e: logs/editor\n?: help Esc: back/clear q: quit\nSelection and viewed subscription are separate.\nClosing this interface leaves the service running."), layout[2]); + } else if ui.details { + let text = ui.selected_row().map(|e| format!("{}\nID: {}\nHost: {}\nPort: {}\nStatus: {}\n{}", e.name, e.id, e.host.as_deref().unwrap_or("unavailable"), e.port.map(|p| p.to_string()).unwrap_or_default(), e.status, e.explanation.as_deref().unwrap_or("Trojan / TCP / TLS"))).unwrap_or_else(|| "No configuration highlighted".into()); + frame.render_widget(Paragraph::new(text), layout[2]); + } else if ui.chooser { + let choices = ui.choices(); + let rows = choices.iter().map(|s| Row::new([s.name.as_str()])); + let mut state = TableState::default().with_selected(Some(ui.chooser_row)); + frame.render_stateful_widget(Table::new(rows, [Constraint::Percentage(100)]).block(Block::default().borders(Borders::ALL).title(format!("Choose subscription / {}", ui.chooser_query))).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); + } else if ui.tab == 0 { + let rows = ui.visible().into_iter().map(|entry| Row::new([ + Cell::from(if entry.selected { "*" } else { " " }), + Cell::from(format!("{}{}", if entry.status == "supported" { String::new() } else { format!("[{}] ", entry.status) }, entry.name)), + Cell::from(entry.host.clone().unwrap_or_default()), Cell::from(entry.ping_ms.map(|v| format!("{v} ms")).unwrap_or_else(|| "?".into())), Cell::from(entry.speed_bytes_per_second.map(|v| format!("{:.1} MiB/s", v / 1048576.0)).unwrap_or_else(|| "?".into())), + ])); + let mut state = TableState::default().with_selected(Some(ui.row)); + frame.render_stateful_widget(Table::new(rows, [Constraint::Length(1), Constraint::Percentage(40), Constraint::Percentage(35), Constraint::Length(9), Constraint::Length(9)]).header(Row::new(["", "Name", "Host", "Delay", "Speed"])).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); + } else if ui.tab == 1 { + let mut state = TableState::default().with_selected(Some(ui.row)); + frame.render_stateful_widget(Table::new(ui.subscriptions.iter().map(|s| Row::new([s.name.as_str()])), [Constraint::Percentage(100)]).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); + } else { + frame.render_widget(Paragraph::new(format!("HTTPS Ping URL: {}\nSpeed URL: {}\nMaximum download MiB: {}\nMaximum duration seconds: {}", ui.settings.ping_url, ui.settings.speed_url, ui.settings.maximum_download_mib, ui.settings.maximum_duration_seconds)), layout[2]); + } + if ui.console { frame.render_widget(Paragraph::new(ui.logs.iter().rev().take(5).cloned().collect::<Vec<_>>().join("\n")).block(Block::default().borders(Borders::TOP)), layout[3]); } + frame.render_widget(Paragraph::new(format!("{} | Routing: {} | Health: {} | Selected: {}\n{}", if ui.status.requested_enabled { "Enabled" } else { "Disabled" }, ui.status.routing, ui.status.health, ui.status.selected_configuration.as_deref().unwrap_or("unset"), ui.message)), layout[4]); + })?; + Ok(()) +} + +fn fuzzy(query: &str, value: &str) -> bool { + let value = value.to_lowercase(); + let mut chars = value.chars(); + query + .to_lowercase() + .chars() + .all(|wanted| chars.by_ref().any(|c| c == wanted)) +} |