//! Background network worker for the desktop frontend. //! //! The core is I/O-free, so the adapter owns the TLS stream. One blocking //! thread keeps the connection, forwards every server line, and wakes the UI //! through `egui::Context::request_repaint`. There is no async runtime: the //! protocol has one request in flight plus server pushes. use std::path::PathBuf; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::thread; use std::time::Duration; use eframe::egui; use rusqlite::Connection; use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt}; use zagan_bet::client::{database, paths}; /// How often the worker checks the socket for pushes when idle. const POLL: Duration = Duration::from_millis(8); /// How long a first-contact prompt may stay unanswered. const TRUST_TIMEOUT: Duration = Duration::from_secs(120); /// What the UI asks the worker to do. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { Connect { addr: String }, Send(String), Disconnect, Stop, } /// Why a connection attempt failed. The UI picks a screen from this. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Failure { TrustDeclined, KeyChanged { expected: String, observed: String }, VersionMismatch, Timeout, Other, } /// A connection that survived trust, version and auto-login. #[derive(Debug, Clone, PartialEq)] pub struct Connected { pub addr: String, pub user: Option, pub auth_error: Option, pub fingerprint: Option, pub latest: String, pub url: String, pub elapsed_ms: f32, } /// What the worker tells the UI. pub enum Event { /// First contact: the UI must answer through the sender. Trust { addr: String, fingerprint: String, reply: Sender, }, Connected(Connected), Failed { addr: String, failure: Failure, message: String, }, Line(String), Closed, } fn classify(error: &TransportError) -> Failure { match error { TransportError::TrustDeclined => Failure::TrustDeclined, TransportError::KeyChanged { expected, observed } => Failure::KeyChanged { expected: expected.clone(), observed: observed.clone(), }, TransportError::VersionMismatch(_) => Failure::VersionMismatch, TransportError::Timeout => Failure::Timeout, _ => Failure::Other, } } /// Handle held by the app. Dropping it stops the worker. pub struct Network { commands: Sender, events: Receiver, } impl Network { pub fn spawn(ctx: egui::Context, data_dir: PathBuf, unsafe_mode: bool) -> Self { let (commands, command_rx) = mpsc::channel(); let (event_tx, events) = mpsc::channel(); thread::spawn(move || worker(ctx, data_dir, unsafe_mode, command_rx, event_tx)); Self { commands, events } } pub fn send(&self, command: Command) { let _ = self.commands.send(command); } pub fn try_recv(&self) -> Option { self.events.try_recv().ok() } } impl Drop for Network { fn drop(&mut self) { let _ = self.commands.send(Command::Stop); } } fn worker( ctx: egui::Context, data_dir: PathBuf, unsafe_mode: bool, commands: Receiver, events: Sender, ) { let db_path = paths::database_path(&data_dir); let conn = match Connection::open(&db_path) { Ok(conn) => { let _ = conn.busy_timeout(Duration::from_secs(2)); database::init_db(&conn); Some(conn) } Err(_) => None, }; let mut stream: Option = None; loop { match commands.recv_timeout(POLL) { Ok(Command::Connect { addr }) => { stream = None; let Some(conn) = &conn else { let _ = events.send(Event::Failed { addr, failure: Failure::Other, message: format!("cannot open {}", db_path.display()), }); ctx.request_repaint(); continue; }; let mut trust = |prompt: &TrustPrompt| { let (reply, decision) = mpsc::channel(); let _ = events.send(Event::Trust { addr: prompt.addr.clone(), fingerprint: prompt.fingerprint.clone(), reply, }); ctx.request_repaint(); decision.recv_timeout(TRUST_TIMEOUT).unwrap_or(false) }; match transport::connect(&addr, conn, unsafe_mode, &mut trust) { Ok(connected) => { let fingerprint = database::get_fingerprint(conn, &addr); let _ = events.send(Event::Connected(Connected { addr: addr.clone(), user: connected.user, auth_error: connected.auth_error, fingerprint, latest: connected.latest, url: connected.url, elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0, })); stream = Some(connected.stream); } Err(error) => { let _ = events.send(Event::Failed { addr, failure: classify(&error), message: error.to_string(), }); } } ctx.request_repaint(); } Ok(Command::Send(line)) => { if let Some(open) = &mut stream { if open.write_line(&line).is_err() { stream = None; let _ = events.send(Event::Closed); } } ctx.request_repaint(); } Ok(Command::Disconnect) => { stream = None; ctx.request_repaint(); } Ok(Command::Stop) => break, Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, } // Drain pushes and replies without blocking the command channel. if let Some(open) = &mut stream { loop { match open.read_available_line() { Ok(Some(line)) => { if events.send(Event::Line(line)).is_err() { return; } } Ok(None) => break, Err(_) => { stream = None; let _ = events.send(Event::Closed); break; } } } ctx.request_repaint(); } } }