use std::{collections::BTreeMap, net::SocketAddr, time::Duration}; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind}; use futures::{FutureExt, StreamExt}; use joecalsend::{ Config, JoecalService, JoecalUploadRequest, Listeners, TransferEvent, UploadDialog, error::Result, models::Device, }; use julid::Julid; use log::{LevelFilter, debug, error, info, warn}; use ratatui::{DefaultTerminal, Frame, widgets::TableState}; use tokio::{ sync::mpsc::{UnboundedReceiver, unbounded_channel}, task::JoinSet, }; pub mod widgets; pub type Peers = BTreeMap; pub struct App { pub service: JoecalService, pub screen: Vec, pub events: EventStream, // addr -> (alias, fingerprint) pub peers: Peers, pub uploads: BTreeMap, upload_state: TableState, // for getting messages back from the web server or web client about things we've done; the // other end is held by the state event_listener: UnboundedReceiver, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CurrentScreen { Main, Sending, Receiving, Stopping, Logging, } #[tokio::main] pub async fn start_and_run( terminal: &mut DefaultTerminal, config: Config, device: Device, ) -> Result<()> { let (event_tx, event_listener) = unbounded_channel(); let service = JoecalService::new(device, event_tx) .await .expect("Could not create JoecalService"); let mut app = App::new(service, event_listener); let mut handles = JoinSet::new(); app.service.start(&config, &mut handles).await; loop { terminal.draw(|frame| app.draw(frame))?; app.handle_events().await?; if let Some(&top) = app.screen.last() && top == CurrentScreen::Stopping { app.service.stop().await; break; } let peers = app.service.peers.lock().await; app.peers.clear(); peers.iter().for_each(|(fingerprint, (addr, device))| { let alias = device.alias.clone(); app.peers .insert(addr.to_owned(), (alias, fingerprint.to_owned())); }); let mut stale_uploads = Vec::new(); let now = chrono::Utc::now().timestamp_millis() as u64; for (id, request) in app.uploads.iter() { if request.tx.is_closed() || (now - id.timestamp()) > 60_000 { stale_uploads.push(*id); } } for id in stale_uploads { app.uploads.remove(&id); } } shutdown(&mut handles).await; Ok(()) } impl App { pub fn new(service: JoecalService, event_listener: UnboundedReceiver) -> Self { App { service, event_listener, screen: vec![CurrentScreen::Main], events: Default::default(), peers: Default::default(), uploads: Default::default(), upload_state: Default::default(), } } async fn handle_events(&mut self) -> Result<()> { tokio::select! { event = self.events.next().fuse() => { if let Some(Ok(evt)) = event { match evt { Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key_event(key), Event::Mouse(_) => {} Event::Resize(_, _) => {} _ => {} } } } transfer_event = self.event_listener.recv() => { if let Some(event) = transfer_event { debug!("got transferr event {event:?}"); match event { TransferEvent::UploadRequest { id, request } => { self.uploads.insert(id, request); } TransferEvent::Cancelled(id) | TransferEvent::Received(id) => { self.uploads.remove(&id); } } } } _ = tokio::time::sleep(Duration::from_millis(200)) => {} } Ok(()) } fn handle_key_event(&mut self, key_event: KeyEvent) { match self.screen.last().unwrap() { CurrentScreen::Logging => match key_event.code { KeyCode::Esc => self.pop(), KeyCode::Left => change_log_level(-1), KeyCode::Right => change_log_level(1), KeyCode::Char('q') => self.exit(), _ => {} }, CurrentScreen::Receiving => match key_event.code { KeyCode::Up => self.upload_state.select_previous(), KeyCode::Down => self.upload_state.select_next(), KeyCode::Char('a') => self.accept(), KeyCode::Char('d') => self.deny(), KeyCode::Esc => self.pop(), KeyCode::Char('q') => self.exit(), _ => {} }, _ => match key_event.code { KeyCode::Char('q') => self.exit(), KeyCode::Char('s') => self.send(), KeyCode::Char('r') => self.recv(), KeyCode::Char('l') => self.logs(), KeyCode::Esc => self.pop(), _ => {} }, } } fn accept(&mut self) { let Some(idx) = self.upload_state.selected() else { return; }; // keys are sorted, so we can use the table selection index let keys: Vec<_> = self.uploads.keys().collect(); let Some(key) = keys.get(idx) else { warn!("could not get id from selection index {idx}"); return; }; let Some(req) = self.uploads.get(key) else { return; }; if let Err(e) = req.tx.send(UploadDialog::UploadConfirm) { error!("got error sending upload confirmation: {e:?}"); }; } fn deny(&mut self) { let Some(idx) = self.upload_state.selected() else { return; }; // keys are sorted, so we can use the table selection index let keys: Vec<_> = self.uploads.keys().cloned().collect(); let Some(key) = keys.get(idx) else { warn!("could not get id from selection index {idx}"); return; }; let Some(req) = self.uploads.get(key).cloned() else { return; }; if let Err(e) = req.tx.send(UploadDialog::UploadDeny) { error!("got error sending upload confirmation: {e:?}"); }; self.uploads.remove(key); } fn draw(&mut self, frame: &mut Frame) { frame.render_widget(self, frame.area()); } fn exit(&mut self) { self.screen.push(CurrentScreen::Stopping); } fn send(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Sending) => {} _ => self.screen.push(CurrentScreen::Sending), } } fn recv(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Receiving) => {} _ => self.screen.push(CurrentScreen::Receiving), } } fn logs(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Logging) => {} _ => self.screen.push(CurrentScreen::Logging), } } fn pop(&mut self) { self.screen.pop(); if self.screen.last().is_none() { self.screen.push(CurrentScreen::Main); } } } fn change_log_level(delta: isize) { let level = log::max_level() as isize; let max = log::LevelFilter::max() as isize; let level = (level + delta).clamp(0, max) as usize; // levelfilter is repr(usize) so this is safe let level = unsafe { std::mem::transmute::(level) }; log::set_max_level(level); } async fn shutdown(handles: &mut JoinSet) { let mut alarm = tokio::time::interval(tokio::time::Duration::from_secs(5)); alarm.tick().await; loop { tokio::select! { join_result = handles.join_next() => { match join_result { Some(handle) => match handle { Ok(h) => info!("Stopped {h:?}"), Err(e) => error!("Got error {e:?}"), } None => break, } } _ = alarm.tick() => { info!("Exit timeout reached, aborting all unjoined tasks"); handles.abort_all(); break; }, } } }