fix(desktop): allow the configured dev URL in allow_webview_navigation

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-09-01 11:20:52 +10:00
parent 92c2e2ffc0
commit 1b171e833b
3 changed files with 65 additions and 8 deletions

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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
));
}