From fc627faf5eb7971d90449eb094f280e3410b250a Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 03:19:01 -0700 Subject: [PATCH 1/6] feat: expose binary compile-target identity (refs #190) --- src/platform/host.rs | 25 +++++++++++++++++++++++++ tests/process_target.rs | 18 ++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/process_target.rs diff --git a/src/platform/host.rs b/src/platform/host.rs index b53b4450..8b8b3c3d 100644 --- a/src/platform/host.rs +++ b/src/platform/host.rs @@ -20,6 +20,31 @@ pub use crate::{ pub use crate::host_login_environment_block as login_environment_block; +/// Compile target of this executing binary, not the physical host hardware. +/// +/// Names follow Rust's target vocabulary (for example `windows`, `macos`, +/// `linux`, `x86_64`, and `aarch64`). Package registry aliases and supported +/// artifact selection are caller policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct ProcessTarget { + /// Operating system the binary was compiled for. + pub os: &'static str, + /// Architecture the binary was compiled for. + pub architecture: &'static str, +} + +/// Report the executing binary's compile target without probing the host. +/// +/// An x86-64 binary running under emulation on ARM64 still reports `x86_64`. +/// This does not detect Rosetta, CPU features, the host's native architecture, +/// or ABI details such as libc. No subprocess or environment lookup is used. +pub const fn process_target() -> ProcessTarget { + ProcessTarget { + os: std::env::consts::OS, + architecture: std::env::consts::ARCH, + } +} + /// Logical concurrency this host exposes to this process. /// /// A thin, host-neutral restatement of [`std::thread::available_parallelism`] diff --git a/tests/process_target.rs b/tests/process_target.rs new file mode 100644 index 00000000..e38cd4c9 --- /dev/null +++ b/tests/process_target.rs @@ -0,0 +1,18 @@ +use kernal_api::platform::host::{process_target, ProcessTarget}; + +#[test] +fn process_target_reports_binary_target_without_host_probing() { + const TARGET: ProcessTarget = process_target(); + assert_eq!(TARGET.os, std::env::consts::OS); + assert_eq!(TARGET.architecture, std::env::consts::ARCH); + #[cfg(target_os = "windows")] + assert_eq!(TARGET.os, "windows"); + #[cfg(target_os = "macos")] + assert_eq!(TARGET.os, "macos"); + #[cfg(target_os = "linux")] + assert_eq!(TARGET.os, "linux"); + #[cfg(target_arch = "x86_64")] + assert_eq!(TARGET.architecture, "x86_64"); + #[cfg(target_arch = "aarch64")] + assert_eq!(TARGET.architecture, "aarch64"); +} From 9c199b4e26f74b923826a10e3c23bd7ce235fdf5 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 03:30:07 -0700 Subject: [PATCH 2/6] feat: add bounded external webview window options (refs #193) --- src/bin/kernal-tauri-smoke.rs | 22 +++++- src/lib.rs | 3 +- src/tauri.rs | 127 ++++++++++++++++++++++++++++++-- tests/webview_window_options.rs | 33 +++++++++ 4 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 tests/webview_window_options.rs diff --git a/src/bin/kernal-tauri-smoke.rs b/src/bin/kernal-tauri-smoke.rs index 953e520b..2bee10a3 100644 --- a/src/bin/kernal-tauri-smoke.rs +++ b/src/bin/kernal-tauri-smoke.rs @@ -11,7 +11,10 @@ use std::sync::mpsc; use std::time::Duration; use kernal_api::async_engine; -use kernal_api::webview::{ExternalWebviewClient, ExternalWebviewHost, WebviewError}; +use kernal_api::webview::{ + ExternalWebviewClient, ExternalWebviewHost, WebviewError, WebviewPermissions, + WebviewWindowOptions, +}; fn main() -> Result<(), Box> { let scenario = SmokeScenario::from_args()?; @@ -137,7 +140,15 @@ async fn lifecycle( url: &str, scenario: SmokeScenario, ) -> Result<(), WebviewError> { - let webview = client.open_webview(url).await?; + let webview = if scenario == SmokeScenario::Close { + let window = WebviewWindowOptions::new("kernal-api configured window", 800, 600) + .map_err(|error| WebviewError::HostFailure(error.to_string()))?; + client + .open_webview_with_options(url, window, WebviewPermissions::deny_all()) + .await? + } else { + client.open_webview(url).await? + }; if scenario == SmokeScenario::Timeout { let timed_out = webview.wait_until_loaded(Duration::from_millis(50)).await; if timed_out != Err(WebviewError::TimedOut) { @@ -158,7 +169,12 @@ async fn lifecycle( } let loaded = webview.wait_until_loaded(Duration::from_secs(30)).await; match (scenario, loaded) { - (SmokeScenario::Close, Ok(())) => webview.close().await, + (SmokeScenario::Close, Ok(())) => { + let expected = WebviewWindowOptions::new("kernal-api configured window", 800, 600) + .map_err(|error| WebviewError::HostFailure(error.to_string()))?; + webview.verify_window_options_for_test(&expected)?; + webview.close().await + } (SmokeScenario::Cancel, Ok(())) => { webview.cancel(); if webview.wait_until_terminal(Duration::ZERO).await != Err(WebviewError::Cancelled) { diff --git a/src/lib.rs b/src/lib.rs index 470c6257..1d874aaa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -111,7 +111,8 @@ pub mod webview { #[cfg(feature = "tauri-webview-test-support")] pub use crate::tauri::WebviewTestObservation; pub use crate::tauri::{ - ExternalWebviewClient, ExternalWebviewHost, WebviewError, WebviewHandle, WebviewPermissions, + ExternalWebviewClient, ExternalWebviewHost, WebviewError, WebviewHandle, + WebviewPermissions, WebviewWindowOptions, WindowOptionsError, }; } diff --git a/src/tauri.rs b/src/tauri.rs index 89432eea..d8eb4011 100644 --- a/src/tauri.rs +++ b/src/tauri.rs @@ -113,6 +113,7 @@ pub(crate) enum NativeWebviewError { pub(crate) struct NativeWebviewRequest { url: Url, permissions: WebviewPermissions, + window: Option, } impl NativeWebviewRequest { @@ -132,7 +133,11 @@ impl NativeWebviewRequest { } let url = Url::parse(url).map_err(|_| NativeWebviewError::InvalidUrl)?; if is_allowed_url(&url) { - Ok(Self { url, permissions }) + Ok(Self { + url, + permissions, + window: None, + }) } else { Err(NativeWebviewError::InvalidUrl) } @@ -225,10 +230,13 @@ impl NativeWebviewBackend { let created_sender = Arc::new(Mutex::new(Some(created_sender))); let native_id = NEXT_LABEL.fetch_add(1, Ordering::Relaxed); let label = format!("kernal-api-webview-{native_id}"); - let pending_window = match PendingWindow::<(), Wry<()>>::new( - WindowBuilderWrapper::new().title("kernal-api external-content proof"), - label, - ) { + let window_builder = match request.window.as_ref() { + Some(options) => WindowBuilderWrapper::new() + .title(options.title()) + .inner_size(f64::from(options.width), f64::from(options.height)), + None => WindowBuilderWrapper::new().title("kernal-api external-content proof"), + }; + let pending_window = match PendingWindow::<(), Wry<()>>::new(window_builder, label) { Ok(window) => window, Err(error) => { let _ = created_sender @@ -645,6 +653,56 @@ impl WebviewPermissions { } } +/// Invalid presentation options, rejected before allocating native resources. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum WindowOptionsError { + #[error("webview title exceeds 1024 UTF-8 bytes or contains a control character")] + InvalidTitle, + #[error("webview logical width and height must each be between 1 and 16384")] + InvalidSize, +} + +/// Validated initial window presentation, independent of page permissions. +/// +/// Dimensions are logical client-area pixels, not physical screen pixels or +/// a guarantee of the page's CSS viewport. Desktop window managers may constrain +/// the requested size. This supplies no script execution or native IPC authority. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebviewWindowOptions { + title: String, + width: u32, + height: u32, +} + +impl WebviewWindowOptions { + /// Validate before copying: title is at most 1024 UTF-8 bytes, with no + /// Unicode control characters; each logical dimension is 1 through 16384. + /// An empty title is allowed. These are input bounds, not GPU-memory quotas. + pub fn new(title: &str, width: u32, height: u32) -> Result { + if title.len() > 1024 || title.chars().any(char::is_control) { + return Err(WindowOptionsError::InvalidTitle); + } + if !(1..=16384).contains(&width) || !(1..=16384).contains(&height) { + return Err(WindowOptionsError::InvalidSize); + } + Ok(Self { + title: title.to_owned(), + width, + height, + }) + } + + /// Requested initial native-window title. + pub fn title(&self) -> &str { + &self.title + } + + /// Requested initial client-area width and height in logical pixels. + pub const fn logical_size(&self) -> (u32, u32) { + (self.width, self.height) + } +} + /// Process-main-thread owner of the opt-in native event loop. /// /// Construct this on the UI/main thread, hand [`ExternalWebviewClient`] to @@ -757,6 +815,29 @@ impl ExternalWebviewClient { permissions: WebviewPermissions, ) -> Result { let request = NativeWebviewRequest::parse(url, permissions).map_err(map_native)?; + self.open_request(request).await + } + + /// Open an isolated external page with validated window presentation. + /// + /// Reuses the same permission, navigation, lifetime, and no-IPC policy as + /// [`Self::open_webview_with_permissions`]. URL validation still precedes + /// native effects. The options constructor performs presentation validation. + pub async fn open_webview_with_options( + &self, + url: &str, + window: WebviewWindowOptions, + permissions: WebviewPermissions, + ) -> Result { + let mut request = NativeWebviewRequest::parse(url, permissions).map_err(map_native)?; + request.window = Some(window); + self.open_request(request).await + } + + async fn open_request( + &self, + request: NativeWebviewRequest, + ) -> Result { let (resource, operation) = self .service .hub @@ -837,6 +918,42 @@ impl ExternalWebviewClient { } impl WebviewHandle { + /// Acceptance-only check of the native title and logical client-area size. + /// Allows one logical pixel of native rounding. Intended for controlled + /// desktops: a window manager may legitimately constrain production sizes. + #[cfg(feature = "tauri-webview-test-support")] + pub fn verify_window_options_for_test( + &self, + expected: &WebviewWindowOptions, + ) -> Result<(), WebviewError> { + // Clone the dispatcher before native synchronous queries: never hold + // the backing-table lock while waiting for the UI thread. + let window = self + .service + .native + .lock() + .map_err(|_| WebviewError::HostFailure("native backing table poisoned".into()))? + .get(&self.resource) + .ok_or(WebviewError::WindowClosed)? + .window + .clone(); + let host_error = |error: tauri_runtime::Error| WebviewError::HostFailure(error.to_string()); + let title = window.title().map_err(host_error)?; + let size = window.inner_size().map_err(host_error)?; + let scale = window.scale_factor().map_err(host_error)?; + if !scale.is_finite() + || scale <= 0.0 + || title != expected.title + || (f64::from(size.width) / scale - f64::from(expected.width)).abs() > 1.0 + || (f64::from(size.height) / scale - f64::from(expected.height)).abs() > 1.0 + { + return Err(WebviewError::HostFailure(format!( + "window presentation mismatch: title={title:?}, physical_size={size:?}, scale={scale}, expected={expected:?}" + ))); + } + Ok(()) + } + /// Await the requested top-level page's matching `Finished` event. /// A timeout revokes this handle and closes the backing native window. pub async fn wait_until_loaded(&self, timeout: Duration) -> Result<(), WebviewError> { diff --git a/tests/webview_window_options.rs b/tests/webview_window_options.rs new file mode 100644 index 00000000..52ab1b61 --- /dev/null +++ b/tests/webview_window_options.rs @@ -0,0 +1,33 @@ +#![cfg(feature = "tauri-webview")] + +use kernal_api::webview::{WebviewWindowOptions, WindowOptionsError}; + +#[test] +fn window_options_preserve_product_title_and_logical_size() { + let options = WebviewWindowOptions::new("FastLED — preview", 1280, 720).unwrap(); + assert_eq!(options.title(), "FastLED — preview"); + assert_eq!(options.logical_size(), (1280, 720)); + assert_eq!(options.clone(), options); +} + +#[test] +fn window_options_reject_invalid_values_before_native_effects() { + for (width, height) in [(0, 1), (1, 0), (16385, 1), (1, 16385), (u32::MAX, 1)] { + assert_eq!( + WebviewWindowOptions::new("title", width, height), + Err(WindowOptionsError::InvalidSize) + ); + } + for title in ["nul\0title", "line\nbreak", "tab\ttitle"] { + assert_eq!( + WebviewWindowOptions::new(title, 1, 1), + Err(WindowOptionsError::InvalidTitle) + ); + } + assert!(WebviewWindowOptions::new("", 1, 16384).is_ok()); + assert!(WebviewWindowOptions::new(&"é".repeat(512), 16384, 1).is_ok()); + assert_eq!( + WebviewWindowOptions::new(&"é".repeat(513), 1, 1), + Err(WindowOptionsError::InvalidTitle) + ); +} From fe054d9eeb53159af9b1c25019cc62116d452db7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 03:43:17 -0700 Subject: [PATCH 3/6] feat: add origin-scoped page bootstrap without host IPC (refs #195) --- .github/workflows/ci.yml | 8 ++ src/bin/kernal-tauri-smoke.rs | 100 ++++++++++++++++++++++- src/lib.rs | 5 +- src/tauri.rs | 138 +++++++++++++++++++++++++++++++- src/tauri/linux_webkitgtk.rs | 21 ++++- tests/webview_page_bootstrap.rs | 19 +++++ 6 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 tests/webview_page_bootstrap.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 889df851..21ab9f39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,13 @@ jobs: run: >- soldr cargo run --locked --all-features --bin kernal-tauri-smoke -- close + - name: Verify native Windows page bootstrap + # WebView2 needs the explicit main-frame guard even with Wry main-only. + if: runner.os == 'Windows' + timeout-minutes: 15 + run: >- + soldr cargo run --locked --all-features + --bin kernal-tauri-smoke -- bootstrap - name: Verify HTTP wire semantics under backend feature unification # Run after locked checks: this deliberately resolves a backend feature # outside our manifest's production graph and may update the lockfile. @@ -221,6 +228,7 @@ jobs: WEBKIT_DISABLE_COMPOSITING_MODE: "1" run: | dbus-run-session -- xvfb-run -a soldr cargo run --locked --features tauri-webview-test-support --bin kernal-tauri-smoke -- close + dbus-run-session -- xvfb-run -a soldr cargo run --locked --features tauri-webview-test-support --bin kernal-tauri-smoke -- bootstrap dbus-run-session -- xvfb-run -a bash -c ' soldr cargo run --locked --features tauri-webview-test-support --bin kernal-tauri-smoke -- popup & proof=$!; for _ in $(seq 1 50); do window=$(xdotool search --name "kernal-api external-content proof" | head -1 || true); [ -n "$window" ] && break; sleep 0.1; done; diff --git a/src/bin/kernal-tauri-smoke.rs b/src/bin/kernal-tauri-smoke.rs index 2bee10a3..590a2fe6 100644 --- a/src/bin/kernal-tauri-smoke.rs +++ b/src/bin/kernal-tauri-smoke.rs @@ -12,8 +12,8 @@ use std::time::Duration; use kernal_api::async_engine; use kernal_api::webview::{ - ExternalWebviewClient, ExternalWebviewHost, WebviewError, WebviewPermissions, - WebviewWindowOptions, + ExternalWebviewClient, ExternalWebviewHost, WebviewError, WebviewPageBootstrap, + WebviewPermissions, WebviewWindowOptions, }; fn main() -> Result<(), Box> { @@ -42,6 +42,9 @@ fn main() -> Result<(), Box> { Err(error) => return Err(error), } }; + if scenario == SmokeScenario::Bootstrap { + return bootstrap_server(&accept, deadline, address.port()); + } if scenario == SmokeScenario::Timeout { let (mut stream, _) = accept().map_err(socket_stage("accept document connection"))?; // Accepted sockets inherit the listener's nonblocking mode on the @@ -140,6 +143,25 @@ async fn lifecycle( url: &str, scenario: SmokeScenario, ) -> Result<(), WebviewError> { + if scenario == SmokeScenario::Bootstrap { + let window = WebviewWindowOptions::new("kernal-api bootstrap proof", 800, 600) + .map_err(|error| WebviewError::HostFailure(error.to_string()))?; + let bootstrap = WebviewPageBootstrap::new("window.__kernal_bootstrap = 17;") + .map_err(|error| WebviewError::HostFailure(error.to_string()))?; + let outcome = match client + .open_webview_with_bootstrap(url, window, WebviewPermissions::deny_all(), bootstrap) + .await + { + Ok(webview) => webview.wait_until_terminal(Duration::from_secs(30)).await, + Err(error) => Err(error), + }; + return match outcome { + Err(WebviewError::RejectedNavigation(_)) => assert_clean(client), + other => Err(WebviewError::HostFailure(format!( + "bootstrap must reject cross-origin navigation, got {other:?}" + ))), + }; + } let webview = if scenario == SmokeScenario::Close { let window = WebviewWindowOptions::new("kernal-api configured window", 800, 600) .map_err(|error| WebviewError::HostFailure(error.to_string()))?; @@ -237,6 +259,7 @@ async fn require_stale(webview: &kernal_api::webview::WebviewHandle) -> Result<( #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SmokeScenario { + Bootstrap, Close, Popup, ProhibitedRedirect, @@ -248,6 +271,7 @@ enum SmokeScenario { impl SmokeScenario { fn from_args() -> Result> { match std::env::args().nth(1).as_deref() { + Some("bootstrap") => Ok(Self::Bootstrap), None | Some("close") => Ok(Self::Close), Some("popup") => Ok(Self::Popup), Some("redirect") => Ok(Self::ProhibitedRedirect), @@ -255,7 +279,7 @@ impl SmokeScenario { Some("cancel") => Ok(Self::Cancel), Some("window-close") => Ok(Self::WindowClose), Some(other) => Err(format!( - "unknown smoke scenario {other:?}; use close, popup, redirect, timeout, cancel, or window-close" + "unknown smoke scenario {other:?}; use close, popup, redirect, timeout, cancel, window-close, or bootstrap" ) .into()), } @@ -263,7 +287,7 @@ impl SmokeScenario { fn page(self, address: &str) -> String { let action = match self { - Self::Close => "", + Self::Close | Self::Bootstrap => "", Self::Timeout | Self::Cancel | Self::WindowClose => "", // WebKit requires a genuine user activation before it invokes the // new-window callback. The Linux Xvfb proof clicks this link with @@ -285,6 +309,74 @@ impl SmokeScenario { } } +fn bootstrap_server( + accept: &impl Fn() -> std::io::Result<(std::net::TcpStream, std::net::SocketAddr)>, + deadline: std::time::Instant, + port: u16, +) -> std::io::Result<()> { + // Every stage must report bootstrap-before-page execution, no subframe + // execution, and absence of host IPC. The second document is a real reload + // into a fresh global, followed by a prohibited same-port/different-host URL. + for stage in 1..=2 { + let (mut document, request) = accept_http_request(accept, deadline) + .map_err(socket_stage("read bootstrap document"))?; + let path = if stage == 1 { "/finished" } else { "/reload" }; + if !request.starts_with(&format!("GET {path} HTTP/1.")) { + return Err(std::io::Error::other(format!( + "unexpected bootstrap document: {request:?}" + ))); + } + let next = if stage == 1 { + "/reload".to_owned() + } else { + format!("http://localhost:{port}/rejected") + }; + let page = format!( + r#""# + ); + write!( + document, + "HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{page}", + page.len() + )?; + drop(document); + let (mut frame, frame_request) = accept_http_request(accept, deadline) + .map_err(socket_stage("read bootstrap subframe"))?; + if !frame_request.starts_with("GET /frame HTTP/1.") { + return Err(std::io::Error::other(format!( + "unexpected bootstrap frame: {frame_request:?}" + ))); + } + let frame_page = + ""; + write!(frame, "HTTP/1.0 200 OK\r\nCache-Control: no-store\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{frame_page}", frame_page.len())?; + drop(frame); + let (mut report, request) = + accept_http_request(accept, deadline).map_err(socket_stage("read bootstrap report"))?; + if !request.starts_with(&format!("GET /_bootstrap?stage={stage}&ok=1 HTTP/1.")) { + return Err(std::io::Error::other(format!( + "bootstrap ordering/frame/isolation proof failed: {request:?}" + ))); + } + report.write_all(b"HTTP/1.0 204 No Content\r\nContent-Length: 0\r\n\r\n")?; + } + Ok(()) +} + fn assert_clean(client: &ExternalWebviewClient) -> Result<(), WebviewError> { let observation = client.test_observation(); if observation.native_backings == 0 diff --git a/src/lib.rs b/src/lib.rs index 1d874aaa..ccb85bca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -111,8 +111,9 @@ pub mod webview { #[cfg(feature = "tauri-webview-test-support")] pub use crate::tauri::WebviewTestObservation; pub use crate::tauri::{ - ExternalWebviewClient, ExternalWebviewHost, WebviewError, WebviewHandle, - WebviewPermissions, WebviewWindowOptions, WindowOptionsError, + ExternalWebviewClient, ExternalWebviewHost, PageBootstrapError, WebviewError, + WebviewHandle, WebviewPageBootstrap, WebviewPermissions, WebviewWindowOptions, + WindowOptionsError, }; } diff --git a/src/tauri.rs b/src/tauri.rs index d8eb4011..e805fc81 100644 --- a/src/tauri.rs +++ b/src/tauri.rs @@ -114,6 +114,7 @@ pub(crate) struct NativeWebviewRequest { url: Url, permissions: WebviewPermissions, window: Option, + bootstrap: Option, } impl NativeWebviewRequest { @@ -137,6 +138,7 @@ impl NativeWebviewRequest { url, permissions, window: None, + bootstrap: None, }) } else { Err(NativeWebviewError::InvalidUrl) @@ -292,6 +294,7 @@ impl NativeWebviewBackend { &window_for_ui, request.url, request.permissions, + request.bootstrap, completion_for_ui, terminal_for_ui, ); @@ -506,6 +509,7 @@ fn build_isolated_webview( dispatcher: &WryWindowDispatcher<()>, target: Url, permissions: WebviewPermissions, + bootstrap: Option, completion: Arc, terminal: Arc, ) -> Result { @@ -516,6 +520,8 @@ fn build_isolated_webview( let completion_for_popup = Arc::clone(&completion); let terminal_for_popup = Arc::clone(&terminal); let completion_for_load = Arc::clone(&completion); + let bootstrap_origin = bootstrap.as_ref().map(|_| target.origin()); + let bootstrap_source = bootstrap.as_ref().map(|script| script.for_origin(&target)); let builder = WebViewBuilder::new() // Deliberately do not call `with_ipc_handler`: Wry documents that it // exposes `window.ipc.postMessage` to page JavaScript. @@ -526,9 +532,14 @@ fn build_isolated_webview( .with_devtools(false) .with_general_autofill_enabled(false) .with_navigation_handler(move |url| match Url::parse(&url) { - Ok(url) if is_allowed_url(&url) => true, + Ok(url) if navigation_allowed(&url, bootstrap_origin.as_ref()) => true, Ok(url) => { - let error = NativeWebviewError::RejectedNavigation(url.scheme().to_owned()); + let reason = if is_allowed_url(&url) { + "cross-origin bootstrap navigation" + } else { + url.scheme() + }; + let error = NativeWebviewError::RejectedNavigation(reason.to_owned()); completion_for_navigation.finish(Err(error.clone())); terminal_for_navigation.finish(Err(error)); false @@ -561,6 +572,14 @@ fn build_isolated_webview( }) .with_download_started_handler(|_, _| false); + // Linux must add the caller script AFTER removing backend scripts/IPC. + #[cfg(not(target_os = "linux"))] + let builder = if let Some(source) = bootstrap_source.as_ref() { + builder.with_initialization_script_for_main_only(source.clone(), true) + } else { + builder + }; + #[cfg(any( target_os = "linux", target_os = "dragonfly", @@ -574,6 +593,9 @@ fn build_isolated_webview( .build_gtk(&dispatcher.default_vbox().map_err(host_failure)?) .map_err(host_failure)?; linux_webkitgtk::remove_host_bridge(&webview.webview())?; + if let Some(source) = bootstrap_source.as_ref() { + linux_webkitgtk::install_page_bootstrap(&webview.webview(), source)?; + } linux_webkitgtk::configure_permissions(&webview.webview(), permissions); webview.load_url(target.as_str()).map_err(host_failure)?; Ok(webview) @@ -653,6 +675,62 @@ impl WebviewPermissions { } } +fn navigation_allowed(url: &Url, required_origin: Option<&url::Origin>) -> bool { + is_allowed_url(url) && required_origin.is_none_or(|origin| *origin == url.origin()) +} + +/// Invalid caller-supplied bootstrap source. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PageBootstrapError { + #[error("page bootstrap exceeds 65536 UTF-8 bytes")] + SourceTooLarge, + #[error("page bootstrap contains a NUL character")] + ContainsNul, +} + +/// Explicit native-caller opt-in to document-start page JavaScript. +/// +/// The trusted caller owns source correctness and effects. Source executes in +/// a block in the main frame's ordinary page world, not an isolated privileged +/// world. No native IPC or guest ABI capability is installed. Runtime syntax +/// errors follow normal page error reporting; they are not host-open errors. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebviewPageBootstrap { + source: String, +} + +impl WebviewPageBootstrap { + /// Validate the 64 KiB byte limit and absence of NUL before copying. + /// This bounds source storage, not execution time or page-side allocations. + pub fn new(source: &str) -> Result { + if source.len() > 65536 { + return Err(PageBootstrapError::SourceTooLarge); + } + if source.contains('\0') { + return Err(PageBootstrapError::ContainsNul); + } + Ok(Self { + source: source.to_owned(), + }) + } + + pub fn source(&self) -> &str { + &self.source + } + + fn for_origin(&self, target: &Url) -> String { + // WebView2 injects into subframes regardless of Wry's main-only flag. + // Guard in page code too, including against initial about:blank. + // The origin is URL-canonicalized and JS-string escaped; source is + // deliberately trusted caller code, never a remote page's input. + format!( + "if (window === window.top && location.origin === \"{}\") {{\n{}\n}}\n", + target.origin().ascii_serialization().escape_default(), + self.source + ) + } +} + /// Invalid presentation options, rejected before allocating native resources. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] pub enum WindowOptionsError { @@ -834,6 +912,29 @@ impl ExternalWebviewClient { self.open_request(request).await } + /// Open with explicit caller-owned main-frame document-start JavaScript. + /// + /// Re-runs on same-origin reloads. All native navigation is restricted to + /// the original HTTP(S) origin (scheme, host, port); crossing it revokes the + /// view. Existing open methods remain script-free. Script correctness, + /// product protocols, and permission choices belong to the caller. + /// Bootstrap origins are limited to 4096 UTF-8 bytes before script wrapping. + pub async fn open_webview_with_bootstrap( + &self, + url: &str, + window: WebviewWindowOptions, + permissions: WebviewPermissions, + bootstrap: WebviewPageBootstrap, + ) -> Result { + let mut request = NativeWebviewRequest::parse(url, permissions).map_err(map_native)?; + if request.url.origin().ascii_serialization().len() > 4096 { + return Err(WebviewError::InvalidUrl); + } + request.window = Some(window); + request.bootstrap = Some(bootstrap); + self.open_request(request).await + } + async fn open_request( &self, request: NativeWebviewRequest, @@ -1224,6 +1325,39 @@ fn map_hub(error: HubError) -> WebviewError { mod tests { use super::*; + #[test] + fn bootstrap_navigation_is_origin_scoped_without_changing_default_route() { + let target = Url::parse("http://127.0.0.1:8080/start").unwrap(); + for allowed in [ + "http://127.0.0.1:8080/reload", + "http://127.0.0.1:8080/start#fragment", + ] { + assert!(navigation_allowed( + &Url::parse(allowed).unwrap(), + Some(&target.origin()) + )); + } + for rejected in [ + "http://localhost:8080/", + "http://127.0.0.1:8081/", + "https://127.0.0.1:8080/", + ] { + let url = Url::parse(rejected).unwrap(); + assert!(!navigation_allowed(&url, Some(&target.origin()))); + assert!(navigation_allowed(&url, None)); + } + assert!(!navigation_allowed( + &Url::parse("file:///tmp/test").unwrap(), + None + )); + let bootstrap = WebviewPageBootstrap::new("window.marker = 1; // comment").unwrap(); + let wrapped = bootstrap.for_origin(&target); + assert!(wrapped.starts_with( + "if (window === window.top && location.origin === \"http://127.0.0.1:8080\") {\n" + )); + assert!(wrapped.ends_with("// comment\n}\n")); + } + #[test] fn external_url_policy_admits_loopback_and_refuses_ambient_schemes() { assert!(NativeWebviewRequest::parse( diff --git a/src/tauri/linux_webkitgtk.rs b/src/tauri/linux_webkitgtk.rs index e5400c54..1e886c2c 100644 --- a/src/tauri/linux_webkitgtk.rs +++ b/src/tauri/linux_webkitgtk.rs @@ -18,7 +18,7 @@ use super::WebviewPermissions; const FALLBACK_FONT_DPI: i32 = 96; /// Published Wry installs an IPC script and endpoint even with no application -/// handler. This capability permits neither, nor any initialization scripts. +/// handler. Remove both before installing any explicitly opted-in caller script. /// Called on the UI thread before the facade initiates the first navigation. pub(super) fn remove_host_bridge( webview: &webkit2gtk::WebView, @@ -99,6 +99,25 @@ fn effective_font_dpi(desktop_gtk_xft_dpi: i32, integer_scale: i32) -> i32 { (desktop_gtk_xft_dpi / integer_scale.max(1)).max(1) } +/// Called after backend bridge removal and before the first navigation. +pub(super) fn install_page_bootstrap( + webview: &webkit2gtk::WebView, + source: &str, +) -> Result<(), super::NativeWebviewError> { + let manager = webview.user_content_manager().ok_or_else(|| { + super::NativeWebviewError::HostFailure("webview has no user content manager".into()) + })?; + let script = webkit2gtk::UserScript::new( + source, + webkit2gtk::UserContentInjectedFrames::TopFrame, + webkit2gtk::UserScriptInjectionTime::Start, + &[], + &[], + ); + manager.add_script(&script); + Ok(()) +} + /// Correct GTK's effective page DPI after GTK initialization but before the /// next WebKitGTK view. This avoids WebKit's negative zoom for unknown DPI and /// preserves KDE/GNOME fractional desktop scale under GTK3 integer scaling. diff --git a/tests/webview_page_bootstrap.rs b/tests/webview_page_bootstrap.rs new file mode 100644 index 00000000..9c472ded --- /dev/null +++ b/tests/webview_page_bootstrap.rs @@ -0,0 +1,19 @@ +#![cfg(feature = "tauri-webview")] + +use kernal_api::webview::{PageBootstrapError, WebviewPageBootstrap}; + +#[test] +fn bootstrap_validates_before_copying_source() { + let source = "window.example = 1; // trailing comment"; + assert_eq!(WebviewPageBootstrap::new(source).unwrap().source(), source); + assert!(WebviewPageBootstrap::new("").is_ok()); + assert!(WebviewPageBootstrap::new(&"a".repeat(65536)).is_ok()); + assert_eq!( + WebviewPageBootstrap::new(&"a".repeat(65537)), + Err(PageBootstrapError::SourceTooLarge) + ); + assert_eq!( + WebviewPageBootstrap::new("a\0b"), + Err(PageBootstrapError::ContainsNul) + ); +} From 8a3cead9d5dddf4726ed92d70f607fd46030bd43 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 04:04:09 -0700 Subject: [PATCH 4/6] feat(webview): add cancellable interactive terminal wait (refs #198) --- .github/workflows/ci.yml | 6 ++++ src/bin/kernal-tauri-smoke.rs | 46 +++++++++++++++++++++++++++-- src/tauri.rs | 55 +++++++++++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ab9f39..922ed88c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,12 @@ jobs: run: >- soldr cargo run --locked --all-features --bin kernal-tauri-smoke -- bootstrap + - name: Verify native Windows interactive window lifetime + if: runner.os == 'Windows' + timeout-minutes: 15 + run: >- + soldr cargo run --locked --all-features + --bin kernal-tauri-smoke -- window-close - name: Verify HTTP wire semantics under backend feature unification # Run after locked checks: this deliberately resolves a backend feature # outside our manifest's production graph and may update the lockfile. diff --git a/src/bin/kernal-tauri-smoke.rs b/src/bin/kernal-tauri-smoke.rs index 590a2fe6..f652d346 100644 --- a/src/bin/kernal-tauri-smoke.rs +++ b/src/bin/kernal-tauri-smoke.rs @@ -199,7 +199,7 @@ async fn lifecycle( } (SmokeScenario::Cancel, Ok(())) => { webview.cancel(); - if webview.wait_until_terminal(Duration::ZERO).await != Err(WebviewError::Cancelled) { + if webview.wait_for_terminal().await != Err(WebviewError::Cancelled) { return Err(WebviewError::HostFailure( "cancellation did not publish its typed terminal outcome".into(), )); @@ -208,8 +208,50 @@ async fn lifecycle( assert_clean(client) } (SmokeScenario::WindowClose, Ok(())) => { + for timed in [false, true] { + let pending = async { + if timed { + webview.wait_until_terminal(Duration::from_secs(30)).await + } else { + webview.wait_for_terminal().await + } + }; + let mut pending = std::pin::pin!(pending); + if async_engine::timeout(Duration::from_millis(20), &mut pending) + .await + .is_ok() + { + return Err(WebviewError::HostFailure( + "interactive wait ended before window closure".into(), + )); + } + if async_engine::timeout(Duration::from_secs(1), webview.wait_for_terminal()) + .await + .map_err(|_| WebviewError::TimedOut)? + != Err(WebviewError::TerminalWaitInProgress) + { + return Err(WebviewError::HostFailure( + "overlapping terminal wait was not rejected".into(), + )); + } + if webview.wait_until_terminal(Duration::ZERO).await + != Err(WebviewError::TerminalWaitInProgress) + { + return Err(WebviewError::HostFailure( + "overlapping timed wait was not rejected".into(), + )); + } + } + let observation = client.test_observation(); + if observation.native_backings != 1 || observation.live_resources != 1 { + return Err(WebviewError::HostFailure(format!( + "cancelled wait revoked its window: {observation:?}" + ))); + } webview.request_window_close_for_test()?; - if webview.wait_until_terminal(Duration::from_secs(5)).await + if async_engine::timeout(Duration::from_secs(5), webview.wait_for_terminal()) + .await + .map_err(|_| WebviewError::TimedOut)? != Err(WebviewError::WindowClosed) { return Err(WebviewError::HostFailure( diff --git a/src/tauri.rs b/src/tauri.rs index e805fc81..1cbed5c0 100644 --- a/src/tauri.rs +++ b/src/tauri.rs @@ -645,6 +645,9 @@ pub enum WebviewError { WindowClosed, #[error("the webview host failed: {0}")] HostFailure(String), + /// Another timed or untimed terminal wait is currently pending. + #[error("a terminal webview wait is already active")] + TerminalWaitInProgress, } /// Semantic permissions for one external webview. @@ -807,6 +810,17 @@ pub struct WebviewHandle { store: u64, resource: OpaqueToken, terminal_operation: OpaqueToken, + terminal_wait_active: AtomicBool, +} + +// The hub terminal operation is single-consumer. Admission belongs to the +// borrowed future so cancellation releases it without revoking the window. +struct TerminalWaitGuard<'a>(&'a AtomicBool); + +impl Drop for TerminalWaitGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } } /// Acceptance-only semantic counters for the process-main-thread smoke test. @@ -990,6 +1004,7 @@ impl ExternalWebviewClient { store: self.store, resource, terminal_operation, + terminal_wait_active: AtomicBool::new(false), }) } Ok(Some(result)) => Err(map_terminal(result.terminal)), @@ -1101,7 +1116,31 @@ impl WebviewHandle { /// This is useful when an allowed top-level document finishes and then /// attempts a prohibited redirect or popup. It is itself a hub-owned /// operation, so callback completion never needs to retain a Store. + /// Only one terminal wait may be active; overlapping timed or untimed + /// waits return [`WebviewError::TerminalWaitInProgress`] without expiring + /// the window. Dropping the future releases that admission. pub async fn wait_until_terminal(&self, timeout: Duration) -> Result<(), WebviewError> { + self.wait_terminal(Some(timeout)).await + } + + /// Await user closure, cancellation, or a terminal host/security event + /// without imposing a lifetime deadline on an interactive window. + /// + /// Owns no timer and does not poll periodically. Dropping this borrowed + /// future leaves the window alive; a subsequent wait observes retained + /// terminal state, including an event that arrived between waits. Dropping + /// or cancelling the handle still revokes the window. As with the timed + /// variant, normal user closure is reported as [`WebviewError::WindowClosed`]. + /// Overlapping terminal waits return [`WebviewError::TerminalWaitInProgress`]. + pub async fn wait_for_terminal(&self) -> Result<(), WebviewError> { + self.wait_terminal(None).await + } + + async fn wait_terminal(&self, timeout: Option) -> Result<(), WebviewError> { + self.terminal_wait_active + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map_err(|_| WebviewError::TerminalWaitInProgress)?; + let _admission = TerminalWaitGuard(&self.terminal_wait_active); // A cancellation or window callback may have completed this operation // before the caller first awaits it. Poll first; if completion wins // the short race before suspension, consume that typed terminal below @@ -1120,12 +1159,16 @@ impl WebviewHandle { .wait_external_operation(self.store, self.terminal_operation) { Ok(wake) => { - if async_engine::timeout(timeout, wake.notified()) - .await - .is_err() - { - self.service - .revoke_with_terminal(self.resource, Terminal::TimedOut); + if let Some(timeout) = timeout { + if async_engine::timeout(timeout, wake.notified()) + .await + .is_err() + { + self.service + .revoke_with_terminal(self.resource, Terminal::TimedOut); + } + } else { + wake.notified().await; } } // Completion can race the poll above; the final observe below From 46344dcf5e154ed2047d541489e15fa3ad403f17 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 04:07:25 -0700 Subject: [PATCH 5/6] test(webview): tolerate bounded browser favicon requests (refs #195) --- src/bin/kernal-tauri-smoke.rs | 37 +++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/bin/kernal-tauri-smoke.rs b/src/bin/kernal-tauri-smoke.rs index 590a2fe6..dbceefc5 100644 --- a/src/bin/kernal-tauri-smoke.rs +++ b/src/bin/kernal-tauri-smoke.rs @@ -403,8 +403,9 @@ fn accept_http_request( accept: &impl Fn() -> std::io::Result<(std::net::TcpStream, std::net::SocketAddr)>, deadline: std::time::Instant, ) -> std::io::Result<(std::net::TcpStream, String)> { - // Browser speculative connections are not documents. Bound both discarded - // connections and total time; never turn a missing report into success. + // Browser speculative connections and automatic favicon requests are not + // proof messages. Bound both discarded connections and total time; never + // turn a missing report into success or discard arbitrary unexpected paths. for _ in 0..16 { if std::time::Instant::now() >= deadline { break; @@ -415,6 +416,13 @@ fn accept_http_request( if let Some(request) = read_http_request(&mut stream, read_deadline)? { stream.set_nonblocking(false)?; stream.set_write_timeout(Some(Duration::from_secs(5)))?; + if matches!( + request.lines().next(), + Some("GET /favicon.ico HTTP/1.0" | "GET /favicon.ico HTTP/1.1") + ) { + stream.write_all(b"HTTP/1.0 204 No Content\r\nContent-Length: 0\r\n\r\n")?; + continue; + } return Ok((stream, request)); } } @@ -482,6 +490,31 @@ fn read_http_request( #[cfg(test)] mod tests { + #[test] + fn browser_favicon_is_answered_before_required_proof_request() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let mut icon = std::net::TcpStream::connect(address).unwrap(); + icon.set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + icon.write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut frame = std::net::TcpStream::connect(address).unwrap(); + frame + .write_all(b"GET /frame HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let (_, request) = super::accept_http_request( + &|| listener.accept(), + std::time::Instant::now() + std::time::Duration::from_secs(2), + ) + .unwrap(); + assert!(request.starts_with("GET /frame HTTP/1.1\r\n")); + let mut response = String::new(); + icon.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.0 204 No Content\r\n")); + } + #[test] fn speculative_connection_is_skipped_before_real_document() { use std::io::Write; From 40736505daa396b2477e88a335d591d12cd6bbd7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 05:30:49 -0700 Subject: [PATCH 6/6] ci: validate lifetime branch after retargeting to main