mod state;
mod view;
use std::io::{self, Stdout};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::Terminal;
use sysinfo::{ProcessesToUpdate, System};
use arora_bridge::{AccessDecision, DeviceInfo};
use crate::operator::{
AccessRequestSummary, AccessRuling, Frontend, Operator, DEFAULT_ACCESS_GRACE,
};
use crate::runtime::Telemetry;
use state::{DeviceIdentity, State};
type SharedState = Arc<Mutex<State>>;
pub struct Tui {
state: SharedState,
running: Arc<AtomicBool>,
restored: Arc<AtomicBool>,
ui: Mutex<Option<JoinHandle<()>>>,
}
impl Tui {
fn start() -> anyhow::Result<Self> {
let state: SharedState = Arc::new(Mutex::new(State::new()));
install_logger(state.clone());
let terminal = setup_terminal()?;
let running = Arc::new(AtomicBool::new(true));
let restored = Arc::new(AtomicBool::new(false));
let ui = std::thread::Builder::new()
.name("arora-tui".to_string())
.spawn({
let state = state.clone();
let running = running.clone();
let restored = restored.clone();
move || run_ui(terminal, state, running, restored)
})?;
Ok(Self {
state,
running,
restored,
ui: Mutex::new(Some(ui)),
})
}
}
#[async_trait]
impl Operator for Tui {
async fn ask_text(&self, label: &str, required: bool) -> Option<String> {
let (question, reply) = state::text_prompt(label.to_string(), required);
if let Ok(mut state) = self.state.lock() {
state.prompts.push_back(question);
}
reply.await.ok().flatten()
}
async fn decide_access(&self, request: &AccessRequestSummary) -> AccessRuling {
let options = vec![
"Allow".to_string(),
"Reject".to_string(),
"Always allow".to_string(),
"Always reject".to_string(),
];
let (question, reply) =
state::decision_prompt(request.describe(), options, Some((DEFAULT_ACCESS_GRACE, 0)));
if let Ok(mut state) = self.state.lock() {
state.prompts.push_back(question);
}
match reply.await.unwrap_or(0) {
1 => AccessRuling {
decision: AccessDecision::Rejected,
remember: false,
},
2 => AccessRuling {
decision: AccessDecision::Allowed,
remember: true,
},
3 => AccessRuling {
decision: AccessDecision::Rejected,
remember: true,
},
_ => AccessRuling {
decision: AccessDecision::Allowed,
remember: false,
},
}
}
}
impl Drop for Tui {
fn drop(&mut self) {
self.running.store(false, Ordering::SeqCst);
if let Some(handle) = self.ui.lock().ok().and_then(|mut ui| ui.take()) {
let _ = handle.join();
}
restore_terminal(&self.restored);
}
}
pub(crate) fn tui_frontend() -> anyhow::Result<Frontend> {
let tui = Arc::new(Tui::start()?);
let state = tui.state.clone();
let operator: Arc<dyn Operator> = tui.clone();
let on_ready = Box::new(
move |telemetry: Telemetry, info: Option<DeviceInfo>, device_id: Option<String>| {
if let Ok(mut state) = state.lock() {
state.telemetry = Some(telemetry);
state.identity = identity_from(info, device_id);
}
},
);
Ok(Frontend { operator, on_ready })
}
fn identity_from(info: Option<DeviceInfo>, device_id: Option<String>) -> DeviceIdentity {
DeviceIdentity {
name: info.as_ref().and_then(|i| i.name.clone()),
device_id,
model_family: info.as_ref().and_then(|i| i.model_family.clone()),
owners: info.map(|i| i.owners).unwrap_or_default(),
}
}
fn run_ui(
mut terminal: Terminal<CrosstermBackend<Stdout>>,
state: SharedState,
running: Arc<AtomicBool>,
restored: Arc<AtomicBool>,
) {
let mut system = System::new();
let pid = sysinfo::get_current_pid().ok();
let mut last_cpu_sample = Instant::now()
.checked_sub(Duration::from_secs(1))
.unwrap_or_else(Instant::now);
let mut quit = false;
while running.load(Ordering::SeqCst) {
if event::poll(Duration::from_millis(100)).unwrap_or(false) {
if let Ok(event) = event::read() {
if let Ok(mut state) = state.lock() {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => state.handle_key(key),
Event::Mouse(mouse) => state.handle_mouse(mouse),
_ => {}
}
}
}
}
if last_cpu_sample.elapsed() >= Duration::from_secs(1) {
let cpu = pid.and_then(|pid| {
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
system.process(pid).map(|process| process.cpu_usage())
});
last_cpu_sample = Instant::now();
if let Ok(mut state) = state.lock() {
state.cpu_percent = cpu;
}
}
let now = Instant::now();
if let Ok(mut state) = state.lock() {
state.tick(now);
if state.quit_requested {
quit = true;
break;
}
let size = terminal.size().unwrap_or_default();
state.viewport_height =
view::log_height(size, state.prompts.front().is_some()) as usize;
let _ = terminal.draw(|frame| view::draw(frame, &state, now));
}
}
restore_terminal(&restored);
if quit {
std::process::exit(0);
}
}
fn install_logger(state: SharedState) {
let mut builder = env_filter::Builder::new();
match std::env::var("RUST_LOG") {
Ok(directives) => {
builder.parse(&directives);
}
Err(_) => {
builder.filter_level(log::LevelFilter::Info);
}
}
let filter = builder.build();
log::set_max_level(filter.filter());
let _ = log::set_boxed_logger(Box::new(TuiLogger { state, filter }));
}
struct TuiLogger {
state: SharedState,
filter: env_filter::Filter,
}
impl log::Log for TuiLogger {
fn enabled(&self, metadata: &log::Metadata) -> bool {
self.filter.enabled(metadata)
}
fn log(&self, record: &log::Record) {
if !self.filter.matches(record) {
return;
}
if let Ok(mut state) = self.state.lock() {
state.push_log(
record.level(),
record.target().to_string(),
record.args().to_string(),
);
}
}
fn flush(&self) {}
}
fn setup_terminal() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}
fn restore_terminal(restored: &AtomicBool) {
if restored.swap(true, Ordering::SeqCst) {
return;
}
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
}