Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions apps/chuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 55 additions & 16 deletions apps/chuzz/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,23 @@ fn commit_render(events: &Latest<DebugEvent>, revision: &mut u64) {
});
}

fn follow_navigation(
destination: Url,
document: &mut ScriptDocument,
origin: &mut String,
load: &impl Fn(Url, Option<&StoredState>) -> Result<Box<ScriptDocument>, String>,
render_events: &Latest<DebugEvent>,
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)?;
Expand Down Expand Up @@ -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)) => {
Expand Down Expand Up @@ -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}")),
}
}
Expand Down
154 changes: 140 additions & 14 deletions apps/chuzz/src/tauri_main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -46,22 +133,32 @@ fn build_menu<R: tauri::Runtime>(
],
)?;

// 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, &copy, &paste, &select_all],
)?;

// `CmdOrCtrl+U` rather than `Cmd+U`, to match what the chrome accepts.
Expand Down Expand Up @@ -92,6 +189,32 @@ fn build_menu<R: tauri::Runtime>(
#[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")]
Expand Down Expand Up @@ -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));
}
});
}
Expand Down
46 changes: 29 additions & 17 deletions apps/chuzz/src/ws_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading