summaryrefslogtreecommitdiffstats
path: root/src/bin/client
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
commit058432130628d00764663ecf819a9adc6793768d (patch)
treed3f49f111fbfaa48647cca2600adcf9017af2d30 /src/bin/client
parentweb: serve latest.php through PHP and refuse stray PHP files (diff)
downloadzagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz
zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip
v5.0.0: protocol v5 and the browser frontend
Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO.
Diffstat (limited to 'src/bin/client')
-rw-r--r--src/bin/client/connection.rs6
-rw-r--r--src/bin/client/repl.rs95
2 files changed, 53 insertions, 48 deletions
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs
index f88f62b..e808d90 100644
--- a/src/bin/client/connection.rs
+++ b/src/bin/client/connection.rs
@@ -8,7 +8,7 @@ use parking_lot::Mutex;
8use std::error::Error; 8use std::error::Error;
9use zagan_bet::client::transport::{self, TrustPrompt}; 9use zagan_bet::client::transport::{self, TrustPrompt};
10use zagan_bet::client::ui::first_contact_lines; 10use zagan_bet::client::ui::first_contact_lines;
11use zagan_bet::version::{is_newer, VERSION}; 11use zagan_bet::version::{VERSION, is_newer};
12 12
13pub use zagan_bet::client::transport::TlsStream; 13pub use zagan_bet::client::transport::TlsStream;
14 14
@@ -21,7 +21,7 @@ pub fn set_unsafe_mode(unsafe_mode: bool) {
21pub fn connect( 21pub fn connect(
22 addr: &str, 22 addr: &str,
23 conn: &rusqlite::Connection, 23 conn: &rusqlite::Connection,
24) -> Result<(TlsStream, Option<String>), Box<dyn Error>> { 24) -> Result<(TlsStream, Option<String>, u64), Box<dyn Error>> {
25 let unsafe_mode = *UNSAFE_MODE.lock(); 25 let unsafe_mode = *UNSAFE_MODE.lock();
26 26
27 // The terminal has no dialog: print the first-contact notice and trust. 27 // The terminal has no dialog: print the first-contact notice and trust.
@@ -53,5 +53,5 @@ pub fn connect(
53 println!("Authentication failed: {error}"); 53 println!("Authentication failed: {error}");
54 } 54 }
55 55
56 Ok((connected.stream, connected.user)) 56 Ok((connected.stream, connected.user, connected.server_time))
57} 57}
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index cfdcdd5..deb5743 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -14,15 +14,15 @@ use crossterm::{
14use sha2::{Digest, Sha256}; 14use sha2::{Digest, Sha256};
15 15
16use zagan_bet::client::core::{ClientCore, Effect}; 16use zagan_bet::client::core::{ClientCore, Effect};
17use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
18use zagan_bet::client::shell::{self, Shell};
19use zagan_bet::client::view::{RoulettePhase, View};
20use zagan_bet::client::database; 17use zagan_bet::client::database;
18use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input};
19use zagan_bet::client::shell::{self, Shell};
21use zagan_bet::client::ui::{ 20use zagan_bet::client::ui::{
22 display_width, format_pong, help_lines, host_lines, input_cursor_column, 21 MIN_COLUMNS, MIN_ROWS, display_width, format_pong, help_lines, host_lines, input_cursor_column,
23 lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, 22 lines_to_move_up, terminal_size_warning, visual_line_count,
24}; 23};
25use zagan_bet::protocol::io_failure_message; 24use zagan_bet::client::view::{RoulettePhase, View};
25use zagan_bet::protocol::{Packet, io_failure_message};
26use zagan_bet::roulette::{WHEEL, color as roulette_color}; 26use zagan_bet::roulette::{WHEEL, color as roulette_color};
27 27
28use crate::connection::TlsStream; 28use crate::connection::TlsStream;
@@ -78,9 +78,10 @@ impl ClientState {
78 pub fn connect_to(&mut self, addr: &str) { 78 pub fn connect_to(&mut self, addr: &str) {
79 self.addr = Some(addr.to_string()); 79 self.addr = Some(addr.to_string());
80 match crate::connection::connect(addr, &self.conn) { 80 match crate::connection::connect(addr, &self.conn) {
81 Ok((stream, authed_user)) => { 81 Ok((stream, authed_user, server_time)) => {
82 self.stream = Some(stream); 82 self.stream = Some(stream);
83 self.core.set_connected(true); 83 self.core.set_connected(true);
84 self.core.set_server_time(server_time);
84 self.core.set_logged_in(authed_user); 85 self.core.set_logged_in(authed_user);
85 let fingerprint = database::get_fingerprint(&self.conn, addr); 86 let fingerprint = database::get_fingerprint(&self.conn, addr);
86 self.core.set_host_info(Some(addr.to_string()), fingerprint); 87 self.core.set_host_info(Some(addr.to_string()), fingerprint);
@@ -126,9 +127,7 @@ impl ClientState {
126 key, 127 key,
127 ) { 128 ) {
128 GameInput::Quit => vec![Effect::Quit], 129 GameInput::Quit => vec![Effect::Quit],
129 GameInput::Command(command) => { 130 GameInput::Command(command) => self.core.command(command),
130 self.core.command(command)
131 }
132 GameInput::None => Vec::new(), 131 GameInput::None => Vec::new(),
133 } 132 }
134 } else { 133 } else {
@@ -189,10 +188,10 @@ impl ClientState {
189 let Some(mut stream) = self.stream.take() else { 188 let Some(mut stream) = self.stream.take() else {
190 return false; 189 return false;
191 }; 190 };
192 let line = match stream.read_available_line() { 191 let packet = match stream.read_available_packet() {
193 Ok(line) => { 192 Ok(packet) => {
194 self.stream = Some(stream); 193 self.stream = Some(stream);
195 line 194 packet
196 } 195 }
197 Err(_) => { 196 Err(_) => {
198 self.core.set_connected(false); 197 self.core.set_connected(false);
@@ -201,13 +200,15 @@ impl ClientState {
201 } 200 }
202 }; 201 };
203 202
204 let Some(line) = line else { 203 let Some(packet) = packet else {
205 return false; 204 return false;
206 }; 205 };
207 206
208 let effects = match self.core.handle_async_line(&line) { 207 let effects = match packet {
209 Some(effects) => effects, 208 Packet::Event { body, .. } => {
210 None => self.core.handle_reply(&line), 209 self.core.handle_async_line(&body).unwrap_or_default()
210 }
211 Packet::Reply { id, body } => self.core.handle_reply_id(id, &body),
211 }; 212 };
212 if self.apply_effects(effects) { 213 if self.apply_effects(effects) {
213 return true; 214 return true;
@@ -259,13 +260,13 @@ impl ClientState {
259 self.draw_region(&lines); 260 self.draw_region(&lines);
260 } 261 }
261 Effect::ClearRegion => self.clear_region(), 262 Effect::ClearRegion => self.clear_region(),
262 Effect::SendLine(line) => { 263 Effect::SendRequest { id, payload } => {
263 let is_ping = line == "PING"; 264 let is_ping = payload == "PING";
264 let started = Instant::now(); 265 let started = Instant::now();
265 let reply = self.send_and_read(&line); 266 let reply = self.send_and_read(id, &payload);
266 let elapsed = started.elapsed(); 267 let elapsed = started.elapsed();
267 let mut more = self.core.handle_reply(&reply); 268 let mut more = self.core.handle_reply_id(id, &reply);
268 if is_ping && reply.trim() == "PONG" { 269 if is_ping && reply.trim().starts_with("PONG") {
269 for effect in &mut more { 270 for effect in &mut more {
270 if let Effect::Print(lines) = effect { 271 if let Effect::Print(lines) = effect {
271 for text in lines { 272 for text in lines {
@@ -488,31 +489,30 @@ impl ClientState {
488 self.prompt_text = text.to_string(); 489 self.prompt_text = text.to_string();
489 } 490 }
490 491
491 fn send_and_read(&mut self, line: &str) -> String { 492 fn send_and_read(&mut self, id: u64, line: &str) -> String {
492 let Some(mut stream) = self.stream.take() else { 493 let Some(mut stream) = self.stream.take() else {
493 return "ERROR Not connected".to_string(); 494 return "ERROR Not connected".to_string();
494 }; 495 };
495 496
496 // A crash event can be pushed while a request is in flight. Route those 497 // A crash event can be pushed while a request is in flight. Route those
497 // lines as they arrive and keep waiting for the actual reply. 498 // events as they arrive and keep waiting for the reply with this id.
498 let mut pushed: Vec<String> = Vec::new(); 499 let mut pushed: Vec<String> = Vec::new();
499 let result = (|| -> std::io::Result<String> { 500 let result = (|| -> std::io::Result<String> {
500 stream.write_line(line)?; 501 stream.write_request(id, None, line)?;
501 loop { 502 loop {
502 let mut response = String::new(); 503 match stream.read_packet()? {
503 let read = stream.read_line(&mut response)?; 504 None => {
504 if read == 0 { 505 return Err(std::io::Error::new(
505 return Err(std::io::Error::new( 506 std::io::ErrorKind::UnexpectedEof,
506 std::io::ErrorKind::UnexpectedEof, 507 "connection closed",
507 "connection closed", 508 ));
508 )); 509 }
509 } 510 Some(Packet::Event { body, .. }) => pushed.push(body),
510 let response = response.trim_end().to_string(); 511 Some(Packet::Reply { id: reply_id, body }) if reply_id == id => {
511 if self.core.is_async(&response) { 512 return Ok(body);
512 pushed.push(response); 513 }
513 continue; 514 Some(Packet::Reply { .. }) => continue,
514 } 515 }
515 return Ok(response);
516 } 516 }
517 })(); 517 })();
518 518
@@ -614,7 +614,11 @@ impl ClientState {
614 short_fingerprint(&identity), 614 short_fingerprint(&identity),
615 host 615 host
616 ), 616 ),
617 Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), 617 Ok(false) => println!(
618 "No saved login for server {} ({}).",
619 short_fingerprint(&identity),
620 host
621 ),
618 Err(error) => println!("Failed to forget the saved login: {}", error), 622 Err(error) => println!("Failed to forget the saved login: {}", error),
619 } 623 }
620 } 624 }
@@ -625,10 +629,9 @@ impl ClientState {
625 return; 629 return;
626 }; 630 };
627 match database::forget_host(&self.conn, &target) { 631 match database::forget_host(&self.conn, &target) {
628 Ok(outcome) if outcome.credentials_removed => println!( 632 Ok(outcome) if outcome.credentials_removed => {
629 "Cleared trust and stored credentials for {}.", 633 println!("Cleared trust and stored credentials for {}.", target)
630 target 634 }
631 ),
632 Ok(outcome) => { 635 Ok(outcome) => {
633 println!("Cleared trust for {}.", target); 636 println!("Cleared trust for {}.", target);
634 if !outcome.siblings.is_empty() { 637 if !outcome.siblings.is_empty() {
@@ -665,7 +668,8 @@ impl ClientState {
665 668
666 let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); 669 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
667 let message = format!("AUTH {} {}", username, hashed); 670 let message = format!("AUTH {} {}", username, hashed);
668 let response = self.send_and_read(&message); 671 let id = self.core.allocate_request_id();
672 let response = self.send_and_read(id, &message);
669 let response = response.trim(); 673 let response = response.trim();
670 674
671 if response.starts_with("OK") { 675 if response.starts_with("OK") {
@@ -709,7 +713,8 @@ impl ClientState {
709 713
710 let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); 714 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
711 let message = format!("REGISTER {} {}", username, hashed); 715 let message = format!("REGISTER {} {}", username, hashed);
712 let response = self.send_and_read(&message); 716 let id = self.core.allocate_request_id();
717 let response = self.send_and_read(id, &message);
713 let response = response.trim(); 718 let response = response.trim();
714 719
715 if response == format!("REGISTERED {}", username) { 720 if response == format!("REGISTERED {}", username) {