diff options
Diffstat (limited to 'gui/src/net.rs')
| -rw-r--r-- | gui/src/net.rs | 220 |
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 | |||
| 8 | use std::path::PathBuf; | ||
| 9 | use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; | ||
| 10 | use std::thread; | ||
| 11 | use std::time::Duration; | ||
| 12 | |||
| 13 | use eframe::egui; | ||
| 14 | use rusqlite::Connection; | ||
| 15 | use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt}; | ||
| 16 | use zagan_bet::client::{database, paths}; | ||
| 17 | |||
| 18 | /// How often the worker checks the socket for pushes when idle. | ||
| 19 | const POLL: Duration = Duration::from_millis(8); | ||
| 20 | /// How long a first-contact prompt may stay unanswered. | ||
| 21 | const TRUST_TIMEOUT: Duration = Duration::from_secs(120); | ||
| 22 | |||
| 23 | /// What the UI asks the worker to do. | ||
| 24 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 25 | pub 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)] | ||
| 34 | pub 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)] | ||
| 44 | pub 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. | ||
| 56 | pub 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 | |||
| 73 | fn 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. | ||
| 87 | pub struct Network { | ||
| 88 | commands: Sender<Command>, | ||
| 89 | events: Receiver<Event>, | ||
| 90 | } | ||
| 91 | |||
| 92 | impl 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 | |||
| 109 | impl Drop for Network { | ||
| 110 | fn drop(&mut self) { | ||
| 111 | let _ = self.commands.send(Command::Stop); | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | fn 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 | } | ||
