From d3e47377457bd1e78406614e6e18616ca36ef2cb Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:28:19 +0100 Subject: [PATCH] fix(app): a browser tab on a port with nothing listening no longer crashes the app The console watcher polled Webview::url twice a second. wry's url() unwraps WKWebView.URL, which is nil after a failed load (connection refused), so the panic aborted the app, and the restored tab crashed every launch after. The watcher now reads location.href through the drain question it already sends. Fixes #36 Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/browser.rs | 72 +++++++++++++++++++++++++----------- docs/changelog.mdx | 4 ++ 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/app/src-tauri/src/browser.rs b/app/src-tauri/src/browser.rs index 66c0a8a7..2d9483cc 100644 --- a/app/src-tauri/src/browser.rs +++ b/app/src-tauri/src/browser.rs @@ -77,10 +77,15 @@ struct Console { data: String, } -// DRAIN asks the page's script for what is new. It always returns a string, -// whatever the page has done to its globals: WebKit's result must be -// something JSON can write. -const DRAIN: &str = "(function(){try{var d=window.__berthDevtools;var s=d&&typeof d.drain==='function'?d.drain():'';return typeof s==='string'?s:'';}catch(e){return '';}})()"; +// DRAIN asks the page for its address and its script for what is new: the +// address, a newline (an address never holds one), then the script's report. +// It always returns a string, whatever the page has done to its globals: +// WebKit's result must be something JSON can write. +// +// The address comes from the page rather than from Webview::url: wry's url() +// unwraps WKWebView.URL, which is nil after a load that failed (nothing +// listening on the port), and the panic aborts the app (tauri-apps/wry#1752). +const DRAIN: &str = "(function(){var u='';try{u=''+location.href;}catch(e){}try{var d=window.__berthDevtools;var s=d&&typeof d.drain==='function'?d.drain():'';return u+'\\n'+(typeof s==='string'?s:'');}catch(e){return u+'\\n';}})()"; // Panes with a watcher, by label: one each, even when a pane reopens. static WATCHED: Mutex>> = Mutex::new(None); @@ -101,34 +106,35 @@ fn watch_console(app: AppHandle, id: String, label: String) { } std::thread::spawn(move || { let asked = Arc::new(AtomicU64::new(0)); - let mut at = String::new(); + let at = Arc::new(Mutex::new(String::new())); loop { std::thread::sleep(Duration::from_millis(500)); let Some(wv) = app.get_webview(&label) else { break }; - // An app that routes on the client (history.pushState) changes - // its address without a navigation or a load, so neither hook - // hears it: the address bar follows the webview's own URL. - if let Ok(u) = wv.url() { - let u = u.to_string(); - if u != at { - if !at.is_empty() { - let _ = app.emit(EVENT, Navigated { id: id.clone(), url: u.clone(), state: "moved" }); - } - at = u; - } - } let since = asked.load(Ordering::SeqCst); if since != 0 && now_ms().saturating_sub(since) < 5000 { continue; } asked.store(now_ms(), Ordering::SeqCst); - let (app2, id2, asked2) = (app.clone(), id.clone(), asked.clone()); + let (app2, id2, asked2, at2) = (app.clone(), id.clone(), asked.clone(), at.clone()); let sent = wv.eval_with_callback(DRAIN, move |json| { asked2.store(0, Ordering::SeqCst); - // The drain's string, as JSON: "" when nothing is new. - let Ok(data) = serde_json::from_str::(&json) else { return }; + // The drain's string, as JSON. + let Ok(answer) = serde_json::from_str::(&json) else { return }; + let (url, data) = split_drain(&answer); + // An app that routes on the client (history.pushState) + // changes its address without a navigation or a load, so + // neither hook hears it: the address bar follows the page's. + if let Some(url) = url { + let mut at = at2.lock().unwrap_or_else(|e| e.into_inner()); + if *at != url { + if !at.is_empty() { + let _ = app2.emit(EVENT, Navigated { id: id2.clone(), url: url.to_string(), state: "moved" }); + } + *at = url.to_string(); + } + } if !data.is_empty() { - let _ = app2.emit(CONSOLE_EVENT, Console { id: id2.clone(), data }); + let _ = app2.emit(CONSOLE_EVENT, Console { id: id2.clone(), data: data.to_string() }); } }); if sent.is_err() { @@ -141,6 +147,15 @@ fn watch_console(app: AppHandle, id: String, label: String) { }); } +// split_drain reads DRAIN's answer: the page's address, when it is a web +// page's (a pane that never loaded one is at about:blank), and the console +// report, "" when nothing is new. +fn split_drain(answer: &str) -> (Option<&str>, &str) { + let (url, data) = answer.split_once('\n').unwrap_or(("", answer)); + let web = url.starts_with("http://") || url.starts_with("https://"); + (web.then_some(url), data) +} + // inspector_in_own_window makes a pane's Web Inspector open in a window of // its own. WebKit docks it into the inspected view's superview by default, // sized as if the view filled it: for a pane laid over the app, that would @@ -365,3 +380,18 @@ pub async fn browser_close(app: AppHandle, id: String) -> Result<(), String> { None => Ok(()), } } + +#[cfg(test)] +mod tests { + use super::split_drain; + + #[test] + fn drain_answers() { + assert_eq!(split_drain("http://localhost:3000/a?b#c\n"), (Some("http://localhost:3000/a?b#c"), "")); + assert_eq!(split_drain("https://x.test/\n{\"logs\":[]}\n"), (Some("https://x.test/"), "{\"logs\":[]}\n")); + // A failed first load leaves the pane at about:blank: no address. + assert_eq!(split_drain("about:blank\n"), (None, "")); + assert_eq!(split_drain("\n"), (None, "")); + assert_eq!(split_drain(""), (None, "")); + } +} diff --git a/docs/changelog.mdx b/docs/changelog.mdx index bae55bd9..9e5609b8 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -13,6 +13,10 @@ again. - **A home widget's ⋯ menu opens again.** It broke the home screen with "Base UI error #31" instead of showing its Size and Refresh options. +- **A Browser tab on a port with nothing listening no longer crashes the + app.** Opening `http://localhost:3000` before its dev server started closed + Shipyard half a second later, and again on every launch after, since the + tab came back. ## v0.3.13