From bf4cf78ab26896faf457273e37d30fc96381c4a4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 21:09:56 +0100 Subject: v4.2.0: wire the GUI to the core, transport and all four games Phase 4. The egui frontend now shares ClientCore and the client database with the terminal: one background worker owns TLS, trust answers go through a channel, and a 10 s request deadline mirrors the terminal socket timeout. First contact, a changed key and a version mismatch are explicit screens; login can remember the credential and auto-login signs in before the login form appears. Adds the lobby with an update notice, a full-width scrollable history table, and the four games painted from View: slots with reel animation and win lines that stop at the last matching reel, roulette with the hover lattice and wheel spin, blackjack with vector-drawn suits, and a smooth crash curve. Sidebars pin their action block; leaving a live hand or round asks before forfeiting. The header balance holds the staked amount while the reels or wheel turn and only shows the payout when they stop. Refused spins cancel the animation. Live crash multipliers keep full precision for the chart; the terminal still displays two decimals. Verified live against a 4.2.0 server: register, login, auto-login, trust, all four games settling, history (50 rows), logout, disconnect, forfeit confirmation, and the version-mismatch screen. Release: 8.3 MB Linux, 6.8 MB Windows, 61 ms to window, 131 MB idle RSS. --- gui/src/net.rs | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 gui/src/net.rs (limited to 'gui/src/net.rs') diff --git a/gui/src/net.rs b/gui/src/net.rs new file mode 100644 index 0000000..d2bc7e4 --- /dev/null +++ b/gui/src/net.rs @@ -0,0 +1,220 @@ +//! 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 server_version: String, + 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, + server_version: connected.server_version, + 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(); + } + } +} -- cgit v1.2.3