diff --git a/Cargo.toml b/Cargo.toml index 14cf3d5..3f502e3 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" @@ -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 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/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}")), } } 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)); } }); } 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