From 528bcea8522b3d69cbacfcd8131b5074ebd8f54d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Gr=C3=B6nke?= Date: Fri, 28 Aug 2026 17:38:06 +0000 Subject: [PATCH 1/3] fix(driver): strip BiDi webSocketUrl capability before forwarding Adapted from tauri-apps/tauri#15605 (fixes tauri-apps/tauri#15415). WebdriverIO 9+ auto-injects webSocketUrl to negotiate BiDi, which tauri-driver cannot proxy and pre-2.46 WebKitGTK rejects. --- .changes/tauri-driver-strip-bidi.md | 6 +++ crates/tauri-driver/README.md | 6 +++ crates/tauri-driver/src/server.rs | 65 +++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 .changes/tauri-driver-strip-bidi.md diff --git a/.changes/tauri-driver-strip-bidi.md b/.changes/tauri-driver-strip-bidi.md new file mode 100644 index 000000000000..1a8cc85bc1d8 --- /dev/null +++ b/.changes/tauri-driver-strip-bidi.md @@ -0,0 +1,6 @@ +--- +"tauri-driver": patch:bug +--- + +Strip the WebDriver BiDi `webSocketUrl` capability from `alwaysMatch` and every `firstMatch` entry before forwarding a new session to the native driver. +Clients like WebdriverIO 9+ auto-inject it, but tauri-driver does not proxy the BiDi websocket and native drivers without BiDi support reject such sessions. diff --git a/crates/tauri-driver/README.md b/crates/tauri-driver/README.md index bbc8794d66fe..db95856519f5 100644 --- a/crates/tauri-driver/README.md +++ b/crates/tauri-driver/README.md @@ -23,6 +23,12 @@ _note: the (probably) items haven't been proof-of-concept'd yet, and if it is not possible to use the listed native webdriver, then a custom implementation will be used that wraps around [wry]._ +## WebDriver BiDi + +`tauri-driver` speaks classic WebDriver and does not proxy the [WebDriver BiDi] websocket. +The BiDi `webSocketUrl` capability (auto-injected by clients like WebdriverIO 9+) is stripped before forwarding the session to the native driver, so clients fall back to classic WebDriver automatically. +With WebdriverIO you can also opt out explicitly by setting `'wdio:enforceWebDriverClassic': true` in your capabilities. + ## Installation You can install tauri-driver using Cargo: diff --git a/crates/tauri-driver/src/server.rs b/crates/tauri-driver/src/server.rs index e0ffaa307c12..f16d6d0894fa 100644 --- a/crates/tauri-driver/src/server.rs +++ b/crates/tauri-driver/src/server.rs @@ -26,6 +26,13 @@ use tokio::net::TcpListener; const TAURI_OPTIONS: &str = "tauri:options"; +// WebDriver BiDi capability auto-injected by clients like WebdriverIO 9+. +// tauri-driver does not proxy the BiDi websocket and native drivers without +// BiDi support (WebKitGTK < 2.46) reject sessions requesting it, so it is +// stripped before forwarding. BiDi is additive; clients fall back to classic +// WebDriver. +const BIDI_CAPABILITY: &str = "webSocketUrl"; + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct TauriOptions { @@ -152,6 +159,8 @@ fn map_capabilities(mut json: Value) -> Value { } } } + + strip_bidi_capabilities(capabilities); } if let Some(native) = native { @@ -166,6 +175,28 @@ fn map_capabilities(mut json: Value) -> Value { json } +/// Removes WebDriver BiDi capabilities the native driver may not honor, +/// from both `alwaysMatch` and every `firstMatch` entry. +fn strip_bidi_capabilities(capabilities: &mut Value) { + if let Some(always_match) = capabilities + .get_mut("alwaysMatch") + .and_then(Value::as_object_mut) + { + always_match.remove(BIDI_CAPABILITY); + } + + if let Some(first_match) = capabilities + .get_mut("firstMatch") + .and_then(Value::as_array_mut) + { + for entry in first_match.iter_mut() { + if let Some(entry) = entry.as_object_mut() { + entry.remove(BIDI_CAPABILITY); + } + } + } +} + #[tokio::main(flavor = "current_thread")] pub async fn run(args: Args, mut _driver: Child) -> Result<(), Error> { #[cfg(unix)] @@ -243,3 +274,37 @@ pub async fn run(args: Args, mut _driver: Child) -> Result<(), Error> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_bidi_from_always_match_and_first_match() { + let json = json!({ + "capabilities": { + "alwaysMatch": { "browserName": "wry", "webSocketUrl": true }, + "firstMatch": [{ "webSocketUrl": true }, { "browserName": "wry" }] + } + }); + + let mapped = map_capabilities(json); + let capabilities = &mapped["capabilities"]; + + assert!(capabilities["alwaysMatch"].get("webSocketUrl").is_none()); + assert_eq!(capabilities["alwaysMatch"]["browserName"], "wry"); + for entry in capabilities["firstMatch"].as_array().unwrap() { + assert!(entry.get("webSocketUrl").is_none()); + } + } + + #[test] + fn strip_ignores_non_object_entries() { + let mut capabilities = json!({ + "alwaysMatch": true, + "firstMatch": [1, "x", { "webSocketUrl": true }] + }); + strip_bidi_capabilities(&mut capabilities); + assert!(capabilities["firstMatch"][2].get("webSocketUrl").is_none()); + } +} From 868f03564cabaa64da7c8208473bbde7f5404aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Gr=C3=B6nke?= Date: Fri, 28 Aug 2026 17:38:15 +0000 Subject: [PATCH 2/3] fix(driver): ensure W3C text field on element send keys Adapted from tauri-apps/tauri#15876 (fixes tauri-apps/tauri#15871). WebKitWebDriver 2.52+ rejects Element Send Keys bodies that only carry the legacy JSON Wire Protocol value array. Co-authored-by: TheRodzz <81969589+TheRodzz@users.noreply.github.com> --- .changes/tauri-driver-send-keys-text.md | 6 ++ crates/tauri-driver/src/server.rs | 101 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 .changes/tauri-driver-send-keys-text.md diff --git a/.changes/tauri-driver-send-keys-text.md b/.changes/tauri-driver-send-keys-text.md new file mode 100644 index 000000000000..98d876b5d2a0 --- /dev/null +++ b/.changes/tauri-driver-send-keys-text.md @@ -0,0 +1,6 @@ +--- +"tauri-driver": patch:bug +--- + +Ensure Element Send Keys requests carry the W3C `text` field by synthesizing it from the legacy JSON Wire Protocol `value` when absent. +WebKitWebDriver 2.52+ rejects bodies without `text` ("Missing text parameter"). diff --git a/crates/tauri-driver/src/server.rs b/crates/tauri-driver/src/server.rs index f16d6d0894fa..bb11b0a37c2a 100644 --- a/crates/tauri-driver/src/server.rs +++ b/crates/tauri-driver/src/server.rs @@ -97,6 +97,18 @@ async fn handle( let bytes = serde_json::to_vec(&json)?; parts.headers.insert(CONTENT_LENGTH, bytes.len().into()); + Request::from_parts(parts, Full::new(bytes.into())) + } else if is_element_send_keys(req.method(), req.uri().path()) { + let (mut parts, body) = req.into_parts(); + + let mut bytes = body.collect().await?.to_bytes().to_vec(); + if let Ok(mut json) = serde_json::from_slice::(&bytes) { + if ensure_send_keys_text(&mut json) { + bytes = serde_json::to_vec(&json)?; + } + } + parts.headers.insert(CONTENT_LENGTH, bytes.len().into()); + Request::from_parts(parts, Full::new(bytes.into())) } else { let (parts, body) = req.into_parts(); @@ -197,6 +209,50 @@ fn strip_bidi_capabilities(capabilities: &mut Value) { } } +/// `true` for the Element Send Keys endpoint (`POST /session/{id}/element/{id}/value`). +fn is_element_send_keys(method: &Method, path: &str) -> bool { + method == Method::POST && { + let mut segments = path.trim_start_matches('/').split('/'); + matches!( + ( + segments.next(), + segments.next(), + segments.next(), + segments.next(), + segments.next(), + segments.next(), + ), + ( + Some("session"), + Some(_), + Some("element"), + Some(_), + Some("value"), + None + ) + ) + } +} + +/// Adds the W3C `text` field to an Element Send Keys body that only carries +/// the legacy JSON Wire Protocol `value`. WebKitWebDriver 2.52+ rejects +/// bodies without `text`. Returns `true` if the body was modified. +fn ensure_send_keys_text(json: &mut Value) -> bool { + let Some(obj) = json.as_object_mut() else { + return false; + }; + if obj.contains_key("text") { + return false; + } + let text = match obj.get("value") { + Some(Value::Array(chunks)) => chunks.iter().filter_map(Value::as_str).collect::(), + Some(Value::String(s)) => s.clone(), + _ => return false, + }; + obj.insert("text".into(), Value::String(text)); + true +} + #[tokio::main(flavor = "current_thread")] pub async fn run(args: Args, mut _driver: Child) -> Result<(), Error> { #[cfg(unix)] @@ -307,4 +363,49 @@ mod tests { strip_bidi_capabilities(&mut capabilities); assert!(capabilities["firstMatch"][2].get("webSocketUrl").is_none()); } + + #[test] + fn matches_element_send_keys_endpoint() { + assert!(is_element_send_keys( + &Method::POST, + "/session/abc/element/def/value" + )); + assert!(!is_element_send_keys( + &Method::GET, + "/session/abc/element/def/value" + )); + assert!(!is_element_send_keys( + &Method::POST, + "/session/abc/element/def/value/extra" + )); + assert!(!is_element_send_keys(&Method::POST, "/session/abc/value")); + assert!(!is_element_send_keys(&Method::POST, "/status")); + } + + #[test] + fn send_keys_text_synthesis() { + // W3C body stays untouched + let mut body = json!({ "text": "hello" }); + assert!(!ensure_send_keys_text(&mut body)); + assert_eq!(body["text"], "hello"); + + // legacy value array gets a text field + let mut body = json!({ "value": ["h", "i"] }); + assert!(ensure_send_keys_text(&mut body)); + assert_eq!(body["text"], "hi"); + assert_eq!(body["value"], json!(["h", "i"])); + + // both fields present stays untouched + let mut body = json!({ "text": "hi", "value": ["h", "i"] }); + assert!(!ensure_send_keys_text(&mut body)); + + // value as plain string + let mut body = json!({ "value": "hello" }); + assert!(ensure_send_keys_text(&mut body)); + assert_eq!(body["text"], "hello"); + + // nothing usable + assert!(!ensure_send_keys_text(&mut json!({}))); + assert!(!ensure_send_keys_text(&mut json!([1, 2]))); + } } From ed37e06534455ec0a9c149b5bb7f68d4e909210a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Gr=C3=B6nke?= Date: Fri, 28 Aug 2026 17:38:23 +0000 Subject: [PATCH 3/3] fix(driver): wait for native driver before accepting connections Based on the unmerged upstream fix/wait-webdriver branch, with a bounded timeout and child crash detection (tauri-apps/tauri#15156). --- .changes/tauri-driver-wait-native.md | 6 ++++ crates/tauri-driver/src/main.rs | 49 +++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .changes/tauri-driver-wait-native.md diff --git a/.changes/tauri-driver-wait-native.md b/.changes/tauri-driver-wait-native.md new file mode 100644 index 000000000000..19e61d01ca24 --- /dev/null +++ b/.changes/tauri-driver-wait-native.md @@ -0,0 +1,6 @@ +--- +"tauri-driver": patch:bug +--- + +Wait until the native WebDriver server accepts connections before serving clients, instead of accepting connections that cannot be proxied yet. +Fails fast when the native driver exits during startup or does not come up within 30 seconds. diff --git a/crates/tauri-driver/src/main.rs b/crates/tauri-driver/src/main.rs index 75795f91c08c..d3f9fd209ce9 100644 --- a/crates/tauri-driver/src/main.rs +++ b/crates/tauri-driver/src/main.rs @@ -40,13 +40,60 @@ fn main() { // start the native webdriver on the port specified in args let mut driver = webdriver::native(&args); - let driver = driver + let mut driver = driver .spawn() .expect("error while running native webdriver"); + // wait until the native webdriver accepts connections, so that we never + // accept client connections we cannot serve yet + if let Err(e) = wait_for_native_driver( + &mut driver, + &args.native_host, + args.native_port, + std::time::Duration::from_secs(30), + ) { + eprintln!("error while waiting for the native webdriver to start: {e}"); + let _ = driver.kill(); + std::process::exit(1); + } + // start our webdriver intermediary node if let Err(e) = server::run(args, driver) { eprintln!("error while running server: {e}"); std::process::exit(1); } } + +#[cfg(any(target_os = "linux", windows))] +fn wait_for_native_driver( + driver: &mut std::process::Child, + host: &str, + port: u16, + timeout: std::time::Duration, +) -> std::io::Result<()> { + use std::io::{Error, ErrorKind}; + + let start = std::time::Instant::now(); + loop { + if let Some(status) = driver.try_wait()? { + return Err(Error::other(format!( + "native webdriver exited before accepting connections: {status}" + ))); + } + + match std::net::TcpStream::connect((host, port)) { + Ok(_) => return Ok(()), + Err(e) if matches!(e.kind(), ErrorKind::ConnectionRefused | ErrorKind::TimedOut) => {} + Err(e) => return Err(e), + } + + if start.elapsed() >= timeout { + return Err(Error::new( + ErrorKind::TimedOut, + format!("native webdriver did not accept connections on {host}:{port} within {timeout:?}"), + )); + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } +}