From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: 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. --- src/bin/client/connection.rs | 6 +-- src/bin/client/repl.rs | 95 +++++++++++++++++++++++--------------------- 2 files changed, 53 insertions(+), 48 deletions(-) (limited to 'src/bin/client') 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; use std::error::Error; use zagan_bet::client::transport::{self, TrustPrompt}; use zagan_bet::client::ui::first_contact_lines; -use zagan_bet::version::{is_newer, VERSION}; +use zagan_bet::version::{VERSION, is_newer}; pub use zagan_bet::client::transport::TlsStream; @@ -21,7 +21,7 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { pub fn connect( addr: &str, conn: &rusqlite::Connection, -) -> Result<(TlsStream, Option), Box> { +) -> Result<(TlsStream, Option, u64), Box> { let unsafe_mode = *UNSAFE_MODE.lock(); // The terminal has no dialog: print the first-contact notice and trust. @@ -53,5 +53,5 @@ pub fn connect( println!("Authentication failed: {error}"); } - Ok((connected.stream, connected.user)) + Ok((connected.stream, connected.user, connected.server_time)) } 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::{ use sha2::{Digest, Sha256}; use zagan_bet::client::core::{ClientCore, Effect}; -use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; -use zagan_bet::client::shell::{self, Shell}; -use zagan_bet::client::view::{RoulettePhase, View}; use zagan_bet::client::database; +use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; +use zagan_bet::client::shell::{self, Shell}; use zagan_bet::client::ui::{ - display_width, format_pong, help_lines, host_lines, input_cursor_column, - lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, + MIN_COLUMNS, MIN_ROWS, display_width, format_pong, help_lines, host_lines, input_cursor_column, + lines_to_move_up, terminal_size_warning, visual_line_count, }; -use zagan_bet::protocol::io_failure_message; +use zagan_bet::client::view::{RoulettePhase, View}; +use zagan_bet::protocol::{Packet, io_failure_message}; use zagan_bet::roulette::{WHEEL, color as roulette_color}; use crate::connection::TlsStream; @@ -78,9 +78,10 @@ impl ClientState { pub fn connect_to(&mut self, addr: &str) { self.addr = Some(addr.to_string()); match crate::connection::connect(addr, &self.conn) { - Ok((stream, authed_user)) => { + Ok((stream, authed_user, server_time)) => { self.stream = Some(stream); self.core.set_connected(true); + self.core.set_server_time(server_time); self.core.set_logged_in(authed_user); let fingerprint = database::get_fingerprint(&self.conn, addr); self.core.set_host_info(Some(addr.to_string()), fingerprint); @@ -126,9 +127,7 @@ impl ClientState { key, ) { GameInput::Quit => vec![Effect::Quit], - GameInput::Command(command) => { - self.core.command(command) - } + GameInput::Command(command) => self.core.command(command), GameInput::None => Vec::new(), } } else { @@ -189,10 +188,10 @@ impl ClientState { let Some(mut stream) = self.stream.take() else { return false; }; - let line = match stream.read_available_line() { - Ok(line) => { + let packet = match stream.read_available_packet() { + Ok(packet) => { self.stream = Some(stream); - line + packet } Err(_) => { self.core.set_connected(false); @@ -201,13 +200,15 @@ impl ClientState { } }; - let Some(line) = line else { + let Some(packet) = packet else { return false; }; - let effects = match self.core.handle_async_line(&line) { - Some(effects) => effects, - None => self.core.handle_reply(&line), + let effects = match packet { + Packet::Event { body, .. } => { + self.core.handle_async_line(&body).unwrap_or_default() + } + Packet::Reply { id, body } => self.core.handle_reply_id(id, &body), }; if self.apply_effects(effects) { return true; @@ -259,13 +260,13 @@ impl ClientState { self.draw_region(&lines); } Effect::ClearRegion => self.clear_region(), - Effect::SendLine(line) => { - let is_ping = line == "PING"; + Effect::SendRequest { id, payload } => { + let is_ping = payload == "PING"; let started = Instant::now(); - let reply = self.send_and_read(&line); + let reply = self.send_and_read(id, &payload); let elapsed = started.elapsed(); - let mut more = self.core.handle_reply(&reply); - if is_ping && reply.trim() == "PONG" { + let mut more = self.core.handle_reply_id(id, &reply); + if is_ping && reply.trim().starts_with("PONG") { for effect in &mut more { if let Effect::Print(lines) = effect { for text in lines { @@ -488,31 +489,30 @@ impl ClientState { self.prompt_text = text.to_string(); } - fn send_and_read(&mut self, line: &str) -> String { + fn send_and_read(&mut self, id: u64, line: &str) -> String { let Some(mut stream) = self.stream.take() else { return "ERROR Not connected".to_string(); }; // A crash event can be pushed while a request is in flight. Route those - // lines as they arrive and keep waiting for the actual reply. + // events as they arrive and keep waiting for the reply with this id. let mut pushed: Vec = Vec::new(); let result = (|| -> std::io::Result { - stream.write_line(line)?; + stream.write_request(id, None, line)?; loop { - let mut response = String::new(); - let read = stream.read_line(&mut response)?; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "connection closed", - )); - } - let response = response.trim_end().to_string(); - if self.core.is_async(&response) { - pushed.push(response); - continue; + match stream.read_packet()? { + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "connection closed", + )); + } + Some(Packet::Event { body, .. }) => pushed.push(body), + Some(Packet::Reply { id: reply_id, body }) if reply_id == id => { + return Ok(body); + } + Some(Packet::Reply { .. }) => continue, } - return Ok(response); } })(); @@ -614,7 +614,11 @@ impl ClientState { short_fingerprint(&identity), host ), - Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), + Ok(false) => println!( + "No saved login for server {} ({}).", + short_fingerprint(&identity), + host + ), Err(error) => println!("Failed to forget the saved login: {}", error), } } @@ -625,10 +629,9 @@ impl ClientState { return; }; match database::forget_host(&self.conn, &target) { - Ok(outcome) if outcome.credentials_removed => println!( - "Cleared trust and stored credentials for {}.", - target - ), + Ok(outcome) if outcome.credentials_removed => { + println!("Cleared trust and stored credentials for {}.", target) + } Ok(outcome) => { println!("Cleared trust for {}.", target); if !outcome.siblings.is_empty() { @@ -665,7 +668,8 @@ impl ClientState { let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); let message = format!("AUTH {} {}", username, hashed); - let response = self.send_and_read(&message); + let id = self.core.allocate_request_id(); + let response = self.send_and_read(id, &message); let response = response.trim(); if response.starts_with("OK") { @@ -709,7 +713,8 @@ impl ClientState { let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); let message = format!("REGISTER {} {}", username, hashed); - let response = self.send_and_read(&message); + let id = self.core.allocate_request_id(); + let response = self.send_and_read(id, &message); let response = response.trim(); if response == format!("REGISTERED {}", username) { -- cgit v1.2.3