summaryrefslogtreecommitdiffstats
path: root/gui/src/net.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
commitbf4cf78ab26896faf457273e37d30fc96381c4a4 (patch)
tree71d67c300b6be191016a988ed5338e15c8b1bdd3 /gui/src/net.rs
parentv4.1.1: pointer-first betting and visible payouts in the GUI (diff)
downloadzagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.tar.gz
zagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.zip
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.
Diffstat (limited to 'gui/src/net.rs')
-rw-r--r--gui/src/net.rs220
1 files changed, 220 insertions, 0 deletions
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 @@
1//! Background network worker for the desktop frontend.
2//!
3//! The core is I/O-free, so the adapter owns the TLS stream. One blocking
4//! thread keeps the connection, forwards every server line, and wakes the UI
5//! through `egui::Context::request_repaint`. There is no async runtime: the
6//! protocol has one request in flight plus server pushes.
7
8use std::path::PathBuf;
9use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
10use std::thread;
11use std::time::Duration;
12
13use eframe::egui;
14use rusqlite::Connection;
15use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt};
16use zagan_bet::client::{database, paths};
17
18/// How often the worker checks the socket for pushes when idle.
19const POLL: Duration = Duration::from_millis(8);
20/// How long a first-contact prompt may stay unanswered.
21const TRUST_TIMEOUT: Duration = Duration::from_secs(120);
22
23/// What the UI asks the worker to do.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Command {
26 Connect { addr: String },
27 Send(String),
28 Disconnect,
29 Stop,
30}
31
32/// Why a connection attempt failed. The UI picks a screen from this.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Failure {
35 TrustDeclined,
36 KeyChanged { expected: String, observed: String },
37 VersionMismatch,
38 Timeout,
39 Other,
40}
41
42/// A connection that survived trust, version and auto-login.
43#[derive(Debug, Clone, PartialEq)]
44pub struct Connected {
45 pub addr: String,
46 pub user: Option<String>,
47 pub auth_error: Option<String>,
48 pub fingerprint: Option<String>,
49 pub server_version: String,
50 pub latest: String,
51 pub url: String,
52 pub elapsed_ms: f32,
53}
54
55/// What the worker tells the UI.
56pub enum Event {
57 /// First contact: the UI must answer through the sender.
58 Trust {
59 addr: String,
60 fingerprint: String,
61 reply: Sender<bool>,
62 },
63 Connected(Connected),
64 Failed {
65 addr: String,
66 failure: Failure,
67 message: String,
68 },
69 Line(String),
70 Closed,
71}
72
73fn classify(error: &TransportError) -> Failure {
74 match error {
75 TransportError::TrustDeclined => Failure::TrustDeclined,
76 TransportError::KeyChanged { expected, observed } => Failure::KeyChanged {
77 expected: expected.clone(),
78 observed: observed.clone(),
79 },
80 TransportError::VersionMismatch(_) => Failure::VersionMismatch,
81 TransportError::Timeout => Failure::Timeout,
82 _ => Failure::Other,
83 }
84}
85
86/// Handle held by the app. Dropping it stops the worker.
87pub struct Network {
88 commands: Sender<Command>,
89 events: Receiver<Event>,
90}
91
92impl Network {
93 pub fn spawn(ctx: egui::Context, data_dir: PathBuf, unsafe_mode: bool) -> Self {
94 let (commands, command_rx) = mpsc::channel();
95 let (event_tx, events) = mpsc::channel();
96 thread::spawn(move || worker(ctx, data_dir, unsafe_mode, command_rx, event_tx));
97 Self { commands, events }
98 }
99
100 pub fn send(&self, command: Command) {
101 let _ = self.commands.send(command);
102 }
103
104 pub fn try_recv(&self) -> Option<Event> {
105 self.events.try_recv().ok()
106 }
107}
108
109impl Drop for Network {
110 fn drop(&mut self) {
111 let _ = self.commands.send(Command::Stop);
112 }
113}
114
115fn worker(
116 ctx: egui::Context,
117 data_dir: PathBuf,
118 unsafe_mode: bool,
119 commands: Receiver<Command>,
120 events: Sender<Event>,
121) {
122 let db_path = paths::database_path(&data_dir);
123 let conn = match Connection::open(&db_path) {
124 Ok(conn) => {
125 let _ = conn.busy_timeout(Duration::from_secs(2));
126 database::init_db(&conn);
127 Some(conn)
128 }
129 Err(_) => None,
130 };
131
132 let mut stream: Option<TlsStream> = None;
133
134 loop {
135 match commands.recv_timeout(POLL) {
136 Ok(Command::Connect { addr }) => {
137 stream = None;
138 let Some(conn) = &conn else {
139 let _ = events.send(Event::Failed {
140 addr,
141 failure: Failure::Other,
142 message: format!("cannot open {}", db_path.display()),
143 });
144 ctx.request_repaint();
145 continue;
146 };
147 let mut trust = |prompt: &TrustPrompt| {
148 let (reply, decision) = mpsc::channel();
149 let _ = events.send(Event::Trust {
150 addr: prompt.addr.clone(),
151 fingerprint: prompt.fingerprint.clone(),
152 reply,
153 });
154 ctx.request_repaint();
155 decision.recv_timeout(TRUST_TIMEOUT).unwrap_or(false)
156 };
157 match transport::connect(&addr, conn, unsafe_mode, &mut trust) {
158 Ok(connected) => {
159 let fingerprint = database::get_fingerprint(conn, &addr);
160 let _ = events.send(Event::Connected(Connected {
161 addr: addr.clone(),
162 user: connected.user,
163 auth_error: connected.auth_error,
164 fingerprint,
165 server_version: connected.server_version,
166 latest: connected.latest,
167 url: connected.url,
168 elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0,
169 }));
170 stream = Some(connected.stream);
171 }
172 Err(error) => {
173 let _ = events.send(Event::Failed {
174 addr,
175 failure: classify(&error),
176 message: error.to_string(),
177 });
178 }
179 }
180 ctx.request_repaint();
181 }
182 Ok(Command::Send(line)) => {
183 if let Some(open) = &mut stream {
184 if open.write_line(&line).is_err() {
185 stream = None;
186 let _ = events.send(Event::Closed);
187 }
188 }
189 ctx.request_repaint();
190 }
191 Ok(Command::Disconnect) => {
192 stream = None;
193 ctx.request_repaint();
194 }
195 Ok(Command::Stop) => break,
196 Err(RecvTimeoutError::Timeout) => {}
197 Err(RecvTimeoutError::Disconnected) => break,
198 }
199
200 // Drain pushes and replies without blocking the command channel.
201 if let Some(open) = &mut stream {
202 loop {
203 match open.read_available_line() {
204 Ok(Some(line)) => {
205 if events.send(Event::Line(line)).is_err() {
206 return;
207 }
208 }
209 Ok(None) => break,
210 Err(_) => {
211 stream = None;
212 let _ = events.send(Event::Closed);
213 break;
214 }
215 }
216 }
217 ctx.request_repaint();
218 }
219 }
220}