From 592ee74ca43b54d7cb20407957e273be677156ec Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 26 Sep 2026 09:48:53 +0700 Subject: [PATCH 1/5] Allow bounded large WebSocket frames for local puzzle QA --- apps/chuzz/src/ws_bridge.rs | 46 +++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/apps/chuzz/src/ws_bridge.rs b/apps/chuzz/src/ws_bridge.rs index 085fdf2..35b3dad 100644 --- a/apps/chuzz/src/ws_bridge.rs +++ b/apps/chuzz/src/ws_bridge.rs @@ -29,7 +29,13 @@ use std::sync::{Arc, Mutex}; use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::protocol::Message; +use tokio_tungstenite::tungstenite::protocol::{Message, WebSocketConfig}; + +// The default 16 MiB frame cap disconnects pages that send a self-contained +// image-rich JSON result in one frame. Keep a bounded cap, with room for the +// 38 MiB OpenCaptchaWorld image-matching response and similarly sized pages. +const MAX_MESSAGE_BYTES: usize = 128 << 20; +const MAX_FRAME_BYTES: usize = 64 << 20; /// One thing that happened to one socket, waiting for the document thread. struct Event { @@ -133,22 +139,28 @@ impl Bridge { } }; - let (stream, response) = match tokio_tungstenite::connect_async(request).await { - Ok(pair) => pair, - Err(error) => { - // The page is told the same thing a browser tells it: the - // connection failed. The detail goes in the error event - // rather than being swallowed, because "it did not - // connect" is the least useful sentence in networking. - bridge.post( - id, - serde_json::json!({"type": "error", "message": format!("{error}")}), - ); - bridge.post(id, closed(1006, "connect")); - bridge.forget(id); - return; - } - }; + let config = WebSocketConfig::default() + .max_message_size(Some(MAX_MESSAGE_BYTES)) + .max_frame_size(Some(MAX_FRAME_BYTES)); + let (stream, response) = + match tokio_tungstenite::connect_async_with_config(request, Some(config), false) + .await + { + Ok(pair) => pair, + Err(error) => { + // The page is told the same thing a browser tells it: the + // connection failed. The detail goes in the error event + // rather than being swallowed, because "it did not + // connect" is the least useful sentence in networking. + bridge.post( + id, + serde_json::json!({"type": "error", "message": format!("{error}")}), + ); + bridge.post(id, closed(1006, "connect")); + bridge.forget(id); + return; + } + }; // The subprotocol the server actually chose, which is not always // the first one offered and is what the page reads back off From 0f785ba42e99daf017be03d737ac8fae8f7c7a9a Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 29 Sep 2026 04:17:11 +0700 Subject: [PATCH 2/5] feat(headless): answer the control protocol's Navigate chuzz-headless refused Navigate, so a QA run could reach a route only by pressing its way there, and a client-routed site never loads a document from a press: a direct load (reloading /account while signed in) could not be tested. Navigate now resolves against the standing document and loads through the path a followed link takes, carrying localStorage and sessionStorage across as that path does. --- apps/chuzz/src/serve.rs | 71 +++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/apps/chuzz/src/serve.rs b/apps/chuzz/src/serve.rs index 72c03b0..20e2067 100644 --- a/apps/chuzz/src/serve.rs +++ b/apps/chuzz/src/serve.rs @@ -442,6 +442,23 @@ fn commit_render(events: &Latest, revision: &mut u64) { }); } +fn follow_navigation( + destination: Url, + document: &mut ScriptDocument, + origin: &mut String, + load: &impl Fn(Url, Option<&StoredState>) -> Result, String>, + render_events: &Latest, + render_revision: &mut u64, +) -> Result<(), String> { + let carried = snapshot_storage(document, origin); + let arriving = destination.origin().ascii_serialization(); + let next = load(destination, Some(&carried))?; + *document = *next; + *origin = arriving; + commit_render(render_events, render_revision); + Ok(()) +} + /// Load `target` and serve it over the inspection socket until killed. pub fn serve(target: &str) -> Result<(), String> { let target = classify(target)?; @@ -658,12 +675,37 @@ pub fn serve(target: &str) -> Result<(), String> { ), Err(error) => DebugResponse::Error(error), }, - // Everything else needs runtime state this mode does not have, - // and saying so is better than a plausible-looking Ack: a check - // that silently did nothing reports the page as broken. + AgentControlRequest::Navigate { url } => { + // Resolved before the match, so the borrow of the standing + // document ends before it is replaced. + let resolved = document.inner().url().join(&url); + match resolved { + Ok(destination) => match follow_navigation( + destination, + &mut document, + &mut origin, + &load, + &render_events, + &mut render_revision, + ) { + Ok(()) => DebugResponse::Ack, + Err(message) => DebugResponse::Error(DebugError { + code: "navigationFailed".into(), + message, + }), + }, + Err(error) => DebugResponse::Error(DebugError { + code: "invalidArgument".into(), + message: format!( + "could not resolve {url:?} against the document URL: {error}" + ), + }), + } + } + // The remaining request types are not implemented by this host. _ => DebugResponse::Error(DebugError { code: "unsupported".into(), - message: "the headless page does not handle process lifecycle requests".into(), + message: "the headless page does not handle this request".into(), }), }, ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Capture(request)) => { @@ -768,18 +810,15 @@ pub fn serve(target: &str) -> Result<(), String> { */ if let Some(destination) = navigation.take() { trace(&format!("following a link to {destination}")); - // Read out before the document that holds it goes. A page writes - // its settings on one route and reads them while booting the next, - // so a store that starts empty makes that read a miss and the - // application look like it never saved. - let carried = snapshot_storage(&mut document, &origin); - let arriving = destination.origin().ascii_serialization(); - match load(destination, Some(&carried)) { - Ok(next) => { - document = next; - origin = arriving; - commit_render(&render_events, &mut render_revision); - } + match follow_navigation( + destination, + &mut document, + &mut origin, + &load, + &render_events, + &mut render_revision, + ) { + Ok(()) => {} Err(error) => trace(&format!("the navigation failed, staying put: {error}")), } } From b3a04afeaf343e1ac843eb75f0f30eb4912d418e Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 29 Sep 2026 04:17:11 +0700 Subject: [PATCH 3/5] fix(macos): paste, copy and cut reach the page and the address bar The Edit menu's predefined items claimed Cmd+Z/X/C/V/A as key equivalents and sent undo:, cut:, copy:, paste: and selectAll: to the first responder, winit's view, which implements none of them. The keystroke died in the menu, so nothing could be pasted anywhere in the window. The items are now chuzz's own with the same accelerators, and each hands the equivalent key down and up to winit's view as a key event, followed by the keyboard's real modifiers so winit does not believe Cmd is held. Blitz's text fields implement the clipboard on Cmd+key already. --- apps/chuzz/Cargo.toml | 18 ++++ apps/chuzz/src/tauri_main.rs | 154 +++++++++++++++++++++++++++++++---- 2 files changed, 158 insertions(+), 14 deletions(-) diff --git a/apps/chuzz/Cargo.toml b/apps/chuzz/Cargo.toml index 9761e8f..c6e8370 100644 --- a/apps/chuzz/Cargo.toml +++ b/apps/chuzz/Cargo.toml @@ -182,6 +182,24 @@ tokio-tungstenite.workspace = true futures-util.workspace = true worktable.workspace = true +# The Edit menu hands its keystrokes to winit's view as key events; see +# `menu_keys` in tauri_main.rs. Already in the tree through winit. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "^0.6" +objc2-app-kit = { version = "^0.3", default-features = false, features = [ + "std", + "NSApplication", + "NSEvent", + "NSGraphicsContext", + "NSResponder", + "NSWindow", +] } +objc2-foundation = { version = "^0.3", default-features = false, features = [ + "std", + "NSGeometry", + "NSString", +] } + [dev-dependencies] # `flavor = "multi_thread"` in the script-fetch test: the blocking fetch takes # the `block_in_place` path, which only the multi-threaded runtime has, and diff --git a/apps/chuzz/src/tauri_main.rs b/apps/chuzz/src/tauri_main.rs index 4726827..3c59ff9 100644 --- a/apps/chuzz/src/tauri_main.rs +++ b/apps/chuzz/src/tauri_main.rs @@ -5,6 +5,93 @@ use tauri::Emitter as _; use tauri::Manager; +/// The Edit menu's keystrokes, handed to the focused view as real key events. +/// +/// A predefined Cut, Copy or Paste item claims Cmd+X, C or V as its key +/// equivalent and then sends `cut:`, `copy:` or `paste:` to the first +/// responder. That responder is winit's view, which implements none of them, +/// so the keystroke died in the menu and no text field in the chrome or the +/// page ever saw it. Blitz implements the clipboard itself on Cmd+key, so the +/// fix is to deliver the key: these items keep the accelerators, and pressing +/// one sends the same key down and up a keyboard would, through winit's own +/// `keyDown:` and `keyUp:`. +/// +/// winit reads the modifiers it reports from `flagsChanged:`, so the pair is +/// followed by one carrying the keyboard's real modifiers. Without it an Edit +/// item chosen with the mouse left winit believing Cmd was still held, and +/// the next click was a Cmd-click. +#[cfg(target_os = "macos")] +mod menu_keys { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSEvent, NSEventModifierFlags, NSEventType}; + use objc2_foundation::{NSPoint, NSString}; + + pub(super) fn dispatch(key: &'static str, key_code: u16, shift: bool) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let application = NSApplication::sharedApplication(mtm); + let Some(window) = application.keyWindow() else { + return; + }; + let Some(responder) = window.firstResponder() else { + return; + }; + + let mut flags = NSEventModifierFlags::Command; + if shift { + flags |= NSEventModifierFlags::Shift; + } + let characters = NSString::from_str(&if shift { + key.to_ascii_uppercase() + } else { + key.to_owned() + }); + let unmodified = NSString::from_str(key); + let event = |kind, flags, characters: &NSString, unmodified: &NSString, key_code| { + NSEvent::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode( + kind, + NSPoint::new(0.0, 0.0), + flags, + 0.0, + window.windowNumber(), + None, + characters, + unmodified, + false, + key_code, + ) + }; + let down = event( + NSEventType::KeyDown, + flags, + &characters, + &unmodified, + key_code, + ); + let up = event( + NSEventType::KeyUp, + flags, + &characters, + &unmodified, + key_code, + ); + let empty = NSString::new(); + let restore = event( + NSEventType::FlagsChanged, + NSEvent::modifierFlags_class(), + &empty, + &empty, + 0, + ); + if let (Some(down), Some(up), Some(restore)) = (down, up, restore) { + responder.keyDown(&down); + responder.keyUp(&up); + responder.flagsChanged(&restore); + } + } +} + /// The macOS menu bar, and the one item in it chuzz adds. /// /// Cmd-U already worked: the binding lives in the Solid chrome, in @@ -15,8 +102,8 @@ use tauri::Manager; /// The whole bar has to be built, not just the one submenu. Setting a menu /// replaces Tauri's default wholesale, so leaving out the app submenu would /// take Quit, Hide and About with it, and leaving out Edit would break Copy and -/// Paste in the address bar. Everything except View is predefined, so this adds -/// an entry rather than reimplementing a menu bar. +/// Paste in the address bar. The app and Window submenus remain predefined, so +/// this adds an entry rather than reimplementing those menu items. /// /// The item emits `menu-view-source` and the chrome runs it through the same /// `runShortcut("view-source")` the keystroke does. A second implementation @@ -46,22 +133,32 @@ fn build_menu( ], )?; - // Without this the address bar cannot copy or paste: on macOS those are - // menu-driven, and the webview never sees the keystroke if no item claims - // it. + // Custom items keep the standard accelerators and send key events through + // Winit's focused responder. Predefined Edit selectors bypass that path. + let undo = MenuItem::with_id(handle, MENU_EDIT_UNDO, "Undo", true, Some("CmdOrCtrl+Z"))?; + let redo = MenuItem::with_id( + handle, + MENU_EDIT_REDO, + "Redo", + true, + Some("CmdOrCtrl+Shift+Z"), + )?; + let separator = PredefinedMenuItem::separator(handle)?; + let cut = MenuItem::with_id(handle, MENU_EDIT_CUT, "Cut", true, Some("CmdOrCtrl+X"))?; + let copy = MenuItem::with_id(handle, MENU_EDIT_COPY, "Copy", true, Some("CmdOrCtrl+C"))?; + let paste = MenuItem::with_id(handle, MENU_EDIT_PASTE, "Paste", true, Some("CmdOrCtrl+V"))?; + let select_all = MenuItem::with_id( + handle, + MENU_EDIT_SELECT_ALL, + "Select All", + true, + Some("CmdOrCtrl+A"), + )?; let edit = Submenu::with_items( handle, "Edit", true, - &[ - &PredefinedMenuItem::undo(handle, None)?, - &PredefinedMenuItem::redo(handle, None)?, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::cut(handle, None)?, - &PredefinedMenuItem::copy(handle, None)?, - &PredefinedMenuItem::paste(handle, None)?, - &PredefinedMenuItem::select_all(handle, None)?, - ], + &[&undo, &redo, &separator, &cut, ©, &paste, &select_all], )?; // `CmdOrCtrl+U` rather than `Cmd+U`, to match what the chrome accepts. @@ -92,6 +189,32 @@ fn build_menu( #[cfg(target_os = "macos")] const MENU_VIEW_SOURCE: &str = "menu-view-source"; +#[cfg(target_os = "macos")] +const MENU_EDIT_UNDO: &str = "menu-edit-undo"; +#[cfg(target_os = "macos")] +const MENU_EDIT_REDO: &str = "menu-edit-redo"; +#[cfg(target_os = "macos")] +const MENU_EDIT_CUT: &str = "menu-edit-cut"; +#[cfg(target_os = "macos")] +const MENU_EDIT_COPY: &str = "menu-edit-copy"; +#[cfg(target_os = "macos")] +const MENU_EDIT_PASTE: &str = "menu-edit-paste"; +#[cfg(target_os = "macos")] +const MENU_EDIT_SELECT_ALL: &str = "menu-edit-select-all"; + +#[cfg(target_os = "macos")] +fn edit_menu_key(id: &str) -> Option<(&'static str, u16, bool)> { + match id { + MENU_EDIT_UNDO => Some(("z", 6, false)), + MENU_EDIT_REDO => Some(("z", 6, true)), + MENU_EDIT_CUT => Some(("x", 7, false)), + MENU_EDIT_COPY => Some(("c", 8, false)), + MENU_EDIT_PASTE => Some(("v", 9, false)), + MENU_EDIT_SELECT_ALL => Some(("a", 0, false)), + _ => None, + } +} + // The modules live in the library beside this binary, because the headless // host is the same browser and compiles the same tree. See `lib.rs`. #[cfg(feature = "capture")] @@ -287,6 +410,9 @@ fn main() { // Emitted rather than handled here: the chrome owns // what view-source means for the active tab. let _ = app.emit(MENU_VIEW_SOURCE, ()); + } else if let Some((key, code, shift)) = edit_menu_key(event.id().as_ref()) { + let _ = + app.run_on_main_thread(move || menu_keys::dispatch(key, code, shift)); } }); } From 16f84e7023a0587d77de0499feda10724554f9d6 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 29 Sep 2026 04:18:55 +0700 Subject: [PATCH 4/5] release: 0.1.41 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 14cf3d5..0390805 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["apps/chuzz", "crates/chuzz-control", "crates/chuzz-qa-fixture"] resolver = "3" [workspace.package] -version = "0.1.40" +version = "0.1.41" edition = "2024" rust-version = "1.91" license = "MIT OR Apache-2.0" From be617cd74de5adc4977c2c0c9609005434851204 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 29 Sep 2026 04:21:31 +0700 Subject: [PATCH 5/5] build: take tauri by major-line caret ^2.11.5 held tauri to a floor where a major-line caret is the rule. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 0390805..3f502e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,7 +119,7 @@ nagoya = "^0.1" tokio = "1" url = "2.5" worktable = "^1.10.0-beta1" -tauri = { version = "^2.11.5", default-features = false } +tauri = { version = "^2", default-features = false } # `diagnostics`, always, rather than behind a cargo feature. # # It is two runtime planes, and neither costs anything until it is switched on