From 1b171e833bc360ebfd476ed48f897dde3a6a00f0 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 11:20:52 +1000 Subject: [PATCH] fix(desktop): allow the configured dev URL in allow_webview_navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Canvas navigation policy only allowed `http://localhost:1420` in debug builds, but every `just` desktop recipe derives a per-worktree Vite port from a hash of the worktree root (`scripts/instance-env.sh`) and injects it as `build.devUrl`. `1420` is only what a bare `pnpm tauri dev` would use. Because wry installs the navigation delegate before it issues the first `loadRequest`, the cancelled navigation was the *initial* page load, not a later link: WKWebView returned `WKNavigationActionPolicy::Cancel`, the bundle never fetched, React never mounted, and the reveal timer gave up with "initial render did not commit before reveal timeout" and showed an empty window. `just dev`, `just staging`, and `just production` all opened blank on macOS; the plugin is macOS-only, so Linux and Windows were unaffected. Take the dev URL as a parameter and compare origins instead of hardcoding a port. `lib.rs` passes `webview.config().build.dev_url`, which is the same config the app was built with — `--config` merges into it via `TAURI_CONFIG` at build time, so it is the origin the webview is actually pointed at. Comparing full origins keeps the containment the old predicate had: a different port, a different host spelling, and a non-http scheme are all still refused, and release builds have no `devUrl`, so plain http stays blocked there. Two regression tests bind the guard to that seam — the configured origin is allowed, and `localhost:1420`, `127.0.0.1` on the same port, and a missing dev URL are not. Reverting to the hardcoded port fails the first one. Verified with `just desktop-standalone`: the app boots on this worktree's port 30164, the webview's Vite HMR client connects and forwards frontend console output, and the reveal-timeout line is gone. Tauri fmt, clippy, and the file-size gate pass; the Tauri suite passes 3059 tests. One unrelated test (`forced_discovery::cheap_discovery_reports_absent_before_any_forced_probe`) fails only under the full parallel run and passes in isolation. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- desktop/src-tauri/src/lib.rs | 7 ++- .../src/project_canvas_package/mod.rs | 13 ++++- .../src/project_canvas_package/tests.rs | 53 +++++++++++++++++-- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c1da83299f..4976cfcfb2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -139,7 +139,12 @@ pub fn run() { #[cfg(target_os = "macos")] let builder = builder.plugin( tauri::plugin::Builder::<_, ()>::new("navigation-policy") - .on_navigation(|_, url| project_canvas_package::allow_webview_navigation(url)) + .on_navigation(|webview, url| { + project_canvas_package::allow_webview_navigation( + url, + webview.config().build.dev_url.as_ref(), + ) + }) .build(), ); let builder = builder diff --git a/desktop/src-tauri/src/project_canvas_package/mod.rs b/desktop/src-tauri/src/project_canvas_package/mod.rs index 57044230d2..10f11230af 100644 --- a/desktop/src-tauri/src/project_canvas_package/mod.rs +++ b/desktop/src-tauri/src/project_canvas_package/mod.rs @@ -532,13 +532,22 @@ fn validate_widget_in_data(data: &serde_json::Value, widget_id: &str) -> Result< } } -pub(crate) fn allow_webview_navigation(url: &tauri::Url) -> bool { +/// Decides whether the main webview may commit a top-level navigation. +/// +/// `dev_url` is the dev server origin the app was actually built against +/// (`webview.config().build.dev_url`). Every `just` desktop recipe derives a +/// per-worktree Vite port via `scripts/instance-env.sh`, so the origin cannot +/// be hardcoded — and because the dev server load *is* the initial navigation, +/// cancelling it leaves a blank window rather than a blocked link. +pub(crate) fn allow_webview_navigation(url: &tauri::Url, dev_url: Option<&tauri::Url>) -> bool { match url.scheme() { "about" => url.as_str() == "about:blank", "buzz-canvas" => url.host_str() == Some("localhost"), "tauri" => url.host_str() == Some("localhost"), + // Debug builds serve the frontend from a dev server; release builds + // have none, so plain http stays blocked there. "http" if cfg!(debug_assertions) => { - url.host_str() == Some("localhost") && url.port() == Some(1420) + dev_url.is_some_and(|dev_url| dev_url.origin() == url.origin()) } _ => false, } diff --git a/desktop/src-tauri/src/project_canvas_package/tests.rs b/desktop/src-tauri/src/project_canvas_package/tests.rs index 8a8c84b1a7..bbbdd723cf 100644 --- a/desktop/src-tauri/src/project_canvas_package/tests.rs +++ b/desktop/src-tauri/src/project_canvas_package/tests.rs @@ -640,19 +640,62 @@ fn protocol_security_policy_has_no_network_or_tauri_ipc_source() { #[test] fn native_navigation_policy_blocks_external_document_navigation() { assert!(super::allow_webview_navigation( - &"buzz-canvas://localhost/load/".parse().unwrap() + &"buzz-canvas://localhost/load/".parse().unwrap(), + None )); assert!(super::allow_webview_navigation( - &"tauri://localhost/".parse().unwrap() + &"tauri://localhost/".parse().unwrap(), + None )); assert!(super::allow_webview_navigation( - &"about:blank".parse().unwrap() + &"about:blank".parse().unwrap(), + None )); assert!(!super::allow_webview_navigation( - &"https://example.com/leak?snapshot=secret".parse().unwrap() + &"https://example.com/leak?snapshot=secret".parse().unwrap(), + None )); assert!(!super::allow_webview_navigation( - &"file:///tmp/secret".parse().unwrap() + &"file:///tmp/secret".parse().unwrap(), + None + )); +} + +// The dev server load is the webview's *initial* navigation, so a policy that +// does not recognise the configured origin opens a blank window. Every `just` +// desktop recipe derives a per-worktree Vite port, so the origin the app is +// launched on is never the `tauri.conf.json` default. +#[test] +fn native_navigation_policy_allows_the_configured_dev_server() { + let dev_url: tauri::Url = "http://localhost:30164".parse().unwrap(); + + assert!(super::allow_webview_navigation( + &"http://localhost:30164/".parse().unwrap(), + Some(&dev_url) + )); + assert!(super::allow_webview_navigation( + &"http://localhost:30164/index.html".parse().unwrap(), + Some(&dev_url) + )); +} + +#[test] +fn native_navigation_policy_blocks_other_localhost_origins() { + let dev_url: tauri::Url = "http://localhost:30164".parse().unwrap(); + + // Some other server on the loopback interface is not the frontend. + assert!(!super::allow_webview_navigation( + &"http://localhost:1420/".parse().unwrap(), + Some(&dev_url) + )); + assert!(!super::allow_webview_navigation( + &"http://127.0.0.1:30164/".parse().unwrap(), + Some(&dev_url) + )); + // Release builds have no dev server, so plain http stays blocked. + assert!(!super::allow_webview_navigation( + &"http://localhost:30164/".parse().unwrap(), + None )); }