diff --git a/Cargo.toml b/Cargo.toml index f53c938..4b2c4d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,27 @@ [package] name = "tauri-runtime-cef" version = "0.1.0" -description = "CEF runtime for Tauri, ported from tauri feat/cef branch onto published crates." -authors = ["Tauri Programme within The Commons Conservancy", "byeongsu-hong"] -homepage = "https://github.com/SableClient/tauri-runtime-cef" -repository = "https://github.com/SableClient/tauri-runtime-cef" -categories = ["gui"] -license = "Apache-2.0 OR MIT" -edition = "2024" -rust-version = "1.88" +# lets `tauri-build` detect the CEF runtime through the `DEP_TAURI_RUNTIME_CEF_RUNTIME` env var +links = "tauri_runtime_cef" +authors.workspace = true +homepage.workspace = true +repository.workspace = true +categories.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true + +[[test]] +name = "macos-application-bootstrap" +harness = false [dependencies] +tauri = { workspace = true } +tauri-macros = { workspace = true } base64 = "0.22" +cef = { version = "=151.8.1", features = ["build-util", "linux-x11"] } # Not actually used directly, just locking it. -cef = { version = "=150.2.1", features = ["build-util", "linux-x11"] } -# Not actually used directly, just locking it. -cef-dll-sys = { version = "=150.2.1", default-features = false } +cef-dll-sys = { version = "=151.8.1", default-features = false } dirs = "6" dioxus-debug-cell = "0.1" http = "1" @@ -26,12 +32,12 @@ raw-window-handle = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -tauri-runtime = "2.11.2" -tauri-utils = { version = "2.9.2", features = [ +tauri-runtime = { version = "2.11.2", path = "../tauri-runtime" } +tauri-utils = { version = "2.9.2", path = "../tauri-utils", features = [ "html-manipulation", ] } url = "2" -winit = "0.31.0-beta.2" +winit = "=0.31.0-beta.2" [target."cfg(windows)".dependencies] softbuffer = { version = "0.4", default-features = false } @@ -47,7 +53,6 @@ windows = { version = "0.61", features = [ ] } [target."cfg(target_os = \"macos\")".dependencies] -dispatch2 = "0.3" objc2 = "0.6" objc2-application-services = { version = "0.3", default-features = false, features = [ "HIServices", @@ -78,6 +83,10 @@ objc2-quartz-core = { version = "0.3", default-features = false, features = [ ] } objc2-foundation = { version = "0.3", default-features = false, features = [ "NSArray", + # NSDate gates the performSelector:withObject:afterDelay: binding used to + # restore the traffic light position after an appearance change. + "NSDate", + "NSNotification", "NSObject", "NSValue", "NSRunLoop", @@ -95,10 +104,13 @@ x11-dl = "2.21" [features] default = ["sandbox"] -devtools = [] -macos-private-api = ["tauri-runtime/macos-private-api"] +# also enables devtools on `tauri` so the feature only needs to be enabled on this crate +devtools = ["tauri-runtime/devtools", "tauri/devtools"] +# also enables macos-private-api on `tauri` so the feature only needs to be enabled on this crate +macos-private-api = [ + "tauri-runtime/macos-private-api", + "tauri/macos-private-api", +] sandbox = ["cef/sandbox"] - -[dev-dependencies] -tauri = { version = "2", default-features = false, features = ["test"] } -tempfile = "3" +# exposes the extension traits for `tauri::webview::WebviewBuilder` +unstable = ["tauri/unstable"] diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9be1cb1 --- /dev/null +++ b/build.rs @@ -0,0 +1,10 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + // exposed to the build scripts of crates depending on this one as `DEP_TAURI_RUNTIME_CEF_RUNTIME`, + // which is how `tauri-build` detects that the application uses the CEF runtime. + println!("cargo:runtime=cef"); +} diff --git a/src/cef_impl/client/display.rs b/src/cef_impl/client/display.rs index 0e4aa7c..f2536ce 100644 --- a/src/cef_impl/client/display.rs +++ b/src/cef_impl/client/display.rs @@ -10,8 +10,8 @@ use crate::webview::INITIAL_LOAD_URL; wrap_display_handler! { pub struct TauriCefDisplayHandler { - document_title_changed_handler: Option>, - address_changed_handler: Option>, + document_title_changed_handler: Option>, + frame_event_handler: Option>, } impl DisplayHandler { @@ -32,19 +32,10 @@ wrap_display_handler! { fn on_address_change( &self, - _browser: Option<&mut Browser>, + browser: Option<&mut Browser>, frame: Option<&mut Frame>, url: Option<&CefString>, ) { - // Only fire for main frame URL changes (matches on_before_browse behavior). - if let Some(frame) = frame - && frame.is_main() == 0 - { - return; - } - let Some(handler) = &self.address_changed_handler else { - return; - }; let Some(url) = url else { return; }; @@ -55,7 +46,12 @@ wrap_display_handler! { } if let Ok(url) = url::Url::parse(&url) { - handler(&url); + crate::frame::emit_frame_event( + &self.frame_event_handler, + browser, + frame, + crate::FrameEventKind::AddressChanged { url }, + ); } } } diff --git a/src/cef_impl/client/download.rs b/src/cef_impl/client/download.rs index 2275533..b6f9489 100644 --- a/src/cef_impl/client/download.rs +++ b/src/cef_impl/client/download.rs @@ -8,7 +8,7 @@ use cef::*; wrap_download_handler! { pub struct TauriCefDownloadHandler { - download_handler: Arc, + download_handler: Arc, } impl DownloadHandler { diff --git a/src/cef_impl/client/drag.rs b/src/cef_impl/client/drag.rs index fd493a2..88f44dd 100644 --- a/src/cef_impl/client/drag.rs +++ b/src/cef_impl/client/drag.rs @@ -16,7 +16,10 @@ use tauri_runtime::{ }; use url::Url; -use crate::runtime::{Message, RuntimeContext}; +use crate::{ + macros::wrap_with_args, + runtime::{Message, RuntimeContext}, +}; const DRAG_DROP_BRIDGE_PATH: &str = "/__tauri_cef_drag_drop__"; @@ -206,7 +209,9 @@ pub(crate) fn event_from_script_event( } } -wrap_resource_request_handler! { +wrap_with_args! { + wrap_resource_request_handler => WebDragDropResourceRequestHandlerArgs; + pub(crate) struct WebDragDropResourceRequestHandler { context: RuntimeContext, window_id: WindowId, diff --git a/src/cef_impl/client/frame.rs b/src/cef_impl/client/frame.rs new file mode 100644 index 0000000..d39612e --- /dev/null +++ b/src/cef_impl/client/frame.rs @@ -0,0 +1,37 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::Arc; + +use cef::*; + +use crate::{FrameEventHandler, FrameEventKind, frame::emit_frame_event}; + +wrap_frame_handler! { + pub struct TauriCefFrameHandler { + handler: Option>, + } + + impl FrameHandler { + fn on_frame_created(&self, browser: Option<&mut Browser>, frame: Option<&mut Frame>) { + emit_frame_event(&self.handler, browser, frame, FrameEventKind::Created); + } + + fn on_frame_attached(&self, browser: Option<&mut Browser>, frame: Option<&mut Frame>, _reattached: ::std::os::raw::c_int) { + emit_frame_event(&self.handler, browser, frame, FrameEventKind::Attached); + } + + fn on_frame_detached(&self, browser: Option<&mut Browser>, frame: Option<&mut Frame>) { + emit_frame_event(&self.handler, browser, frame, FrameEventKind::Detached); + } + + fn on_frame_destroyed(&self, browser: Option<&mut Browser>, frame: Option<&mut Frame>) { + emit_frame_event(&self.handler, browser, frame, FrameEventKind::Destroyed); + } + + fn on_main_frame_changed(&self, browser: Option<&mut Browser>, _old_frame: Option<&mut Frame>, new_frame: Option<&mut Frame>) { + emit_frame_event(&self.handler, browser, new_frame, FrameEventKind::MainFrameChanged); + } + } +} diff --git a/src/cef_impl/client/life_span.rs b/src/cef_impl/client/life_span.rs index 35de171..83f5190 100644 --- a/src/cef_impl/client/life_span.rs +++ b/src/cef_impl/client/life_span.rs @@ -2,13 +2,49 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::sync::{Arc, mpsc::Sender}; +use std::sync::{Arc, Weak, mpsc::Sender}; use cef::*; -use tauri_runtime::{UserEvent, window::WindowId}; +use tauri_runtime::{ + UserEvent, + dpi::{LogicalPosition, LogicalSize}, + window::WindowId, +}; use winit::event_loop::EventLoopProxy as WinitEventLoopProxy; -use crate::runtime::{Message, RuntimeContext}; +use crate::{ + macros::wrap_with_args, + runtime::{CefRuntime, Message, NewWindowOpener, RuntimeContext}, +}; + +pub(super) type PopupClientFactory = + dyn Fn(crate::popup::PopupRequest, crate::FrameNavigationState) -> Client; + +#[cfg(test)] +mod tests { + use super::*; + use tauri_runtime::webview::NewWindowFeatures; + + #[test] + fn popup_source_observation_is_available_without_dispatch_and_redacted_from_debug() { + let features = + NewWindowFeatures::<(), CefRuntime<()>>::new(None, None, NewWindowOpener::new(None)); + assert!(features.opener().source_url().is_none()); + assert!(format!("{features:?}").contains("source_url_observed: false")); + + let source = url::Url::parse("https://example.com/private?token=fixture-secret").unwrap(); + let features = NewWindowFeatures::<(), CefRuntime<()>>::new( + None, + None, + NewWindowOpener::new(Some(source.clone())), + ); + assert_eq!(features.opener().source_url(), Some(&source)); + let debug = format!("{features:?}"); + assert!(debug.contains("source_url_observed: true")); + assert!(!debug.contains("private")); + assert!(!debug.contains("fixture-secret")); + } +} // There is some race condition on CEF that causes the app loading to fail // when there is a network service crash: @@ -42,21 +78,37 @@ fn check_and_reload_if_blank(browser: cef::Browser, initial_url: String) { }); } -wrap_life_span_handler! { +wrap_with_args! { + wrap_life_span_handler => TauriCefChildLifeSpanHandlerArgs; + pub struct TauriCefChildLifeSpanHandler { sender: Sender>, proxy: WinitEventLoopProxy, window_id: WindowId, webview_id: u32, - webview_label: String, context: RuntimeContext, - new_window_handler: Option>, + new_window_handler: Option>>>, initial_url: Option, + frame_navigation_state: crate::FrameNavigationState, + popup_family: Weak, + opener: Option, + create_popup: Arc, } impl LifeSpanHandler { fn on_after_created(&self, browser: Option<&mut Browser>) { + if let (Some(browser), Some(opener)) = (browser.as_deref(), self.opener.as_ref()) { + if let Some(family) = self.popup_family.upgrade() { + let _ = self.sender.send(Message::PopupCreated(opener.clone(), browser.identifier(), family.clone())); + self.proxy.wake_up(); + family.created(browser, opener, &self.frame_navigation_state); + } else if let Some(host) = browser.host() { + host.close_browser(1); + } + return; + } if let Some(browser) = browser + && browser.is_popup() == 0 && let Some(initial_url) = &self.initial_url { check_and_reload_if_blank(browser.clone(), initial_url.clone()); @@ -65,48 +117,125 @@ wrap_life_span_handler! { fn on_before_popup( &self, - _browser: Option<&mut Browser>, + browser: Option<&mut Browser>, _frame: Option<&mut Frame>, - _popup_id: std::os::raw::c_int, + popup_id: std::os::raw::c_int, target_url: Option<&CefString>, _target_frame_name: Option<&CefString>, _target_disposition: WindowOpenDisposition, _user_gesture: std::os::raw::c_int, - _popup_features: Option<&PopupFeatures>, + popup_features: Option<&PopupFeatures>, _window_info: Option<&mut WindowInfo>, - _client: Option<&mut Option>, + client: Option<&mut Option>, _settings: Option<&mut BrowserSettings>, _extra_info: Option<&mut Option>, _no_javascript_access: Option<&mut i32>, ) -> std::os::raw::c_int { - // Return value: 0 = allow the popup, 1 = cancel it. - // A crate-level popup policy (set_popup_policy) decides per URL/label - // when installed. - let url = target_url.map(|u| u.to_string()).unwrap_or_default(); - if let Some(allow) = crate::policy::popup_allowed(&crate::policy::PopupRequest { - webview_label: &self.webview_label, - url: &url, - }) { - return i32::from(!allow); + let url_str = target_url.map(ToString::to_string).unwrap_or_default(); + let response = if let Some(handler) = &self.new_window_handler { + let Ok(url) = url::Url::parse(&url_str) else { return 1; }; + // window.open features are CSS pixels, which map to logical units. + let size = popup_features.and_then(|features| { + (features.width_set != 0 && features.height_set != 0) + .then(|| LogicalSize::new(features.width as f64, features.height as f64)) + }); + let position = popup_features.and_then(|features| { + (features.x_set != 0 && features.y_set != 0) + .then(|| LogicalPosition::new(features.x as f64, features.y as f64)) + }); + let source_url = browser.as_deref() + .and_then(|browser| browser.main_frame()) + .and_then(|frame| url::Url::parse(&CefString::from(&frame.url()).to_string()).ok()); + handler(url, tauri_runtime::webview::NewWindowFeatures::new(size, position, NewWindowOpener::new(source_url))) + } else { tauri_runtime::webview::NewWindowResponse::Allow }; + match response { + tauri_runtime::webview::NewWindowResponse::Allow => { + let (Some(browser), Some(client), Some(family)) = + (browser, client, self.popup_family.upgrade()) else { return 1; }; + let Some(request) = family.reserve(&self.frame_navigation_state, browser.identifier(), popup_id) else { return 1; }; + if self.sender.send(Message::PopupPending(request.clone(), family.clone())).is_err() { + family.abort(&self.frame_navigation_state, popup_id); + return 1; + } + self.proxy.wake_up(); + // Keep CEF's popup creation and JavaScript opener relationship. Only + // its client changes: root IPC, load/title callbacks and close handling + // cannot be inherited by a different native browser lifetime. + *client = Some((self.create_popup)(request, crate::FrameNavigationState::new())); + 0 + }, + tauri_runtime::webview::NewWindowResponse::Create { window_id } => { + // CEF cannot transplant a popup's contents into an existing + // browser, so cancel the popup and navigate the designated + // window's first webview to the URL instead — the closest + // equivalent of wry hosting the popup in that window's webview. + // Note `window.opener` is not linked to the new document. + let _ = self.context.send_message(Message::NavigateFirstWebview { + window_id, + url: url_str, + }); + 1 + } + tauri_runtime::webview::NewWindowResponse::Deny => 1, } - // ponytail: published tauri's new-window handler cannot be invoked from - // CEF — its NewWindowFeatures wraps a wry platform webview handle - // (webkit2gtk::WebView on Linux) that a CEF browser cannot construct. - // An installed handler therefore degrades to a popup deny (the - // verdict every current caller returns); no handler keeps CEF's native - // popup behavior. Revisit when upstream releases feat/cef's - // runtime-generic opener. - i32::from(self.new_window_handler.is_some()) + } + + fn on_before_popup_aborted(&self, browser: Option<&mut Browser>, popup_id: std::os::raw::c_int) { + let Some(browser) = browser else { return; }; + if !self.frame_navigation_state.has_browser_id(browser.identifier()) { return; } + if let Some(family) = self.popup_family.upgrade() + && let Some(request) = family.abort(&self.frame_navigation_state, popup_id) { + let _ = self.sender.send(Message::PopupAborted(request)); + self.proxy.wake_up(); + } + } + + /// Take over the browser close so it does not take the window down with it. + /// + /// Returning 0 runs CEF's default, which sends the standard close + /// notification to the browser's *top-level parent window* (`performClose:` + /// on macOS, `WM_CLOSE` on Windows). Every Tauri webview is a child browser + /// parented to a shared window, so that default turns "close this webview" + /// into "close the window and every sibling webview" — and with the last + /// window gone, the app exits. + /// + /// Returning 1 leaves the parent window alone and makes us responsible for + /// completing the close, which means destroying this browser's own child + /// view/window on the event loop. That destruction is what drives CEF's + /// `WindowDestroyed` -> `on_before_close` sequence. + /// + /// On Linux the default only closes the browser's own X11 child window (and + /// calls `WindowDestroyed` itself), so the default is already correct there. + fn do_close(&self, browser: Option<&mut Browser>) -> std::os::raw::c_int { + if browser.as_ref().is_none_or(|browser| browser.is_popup() != 0) { + return 0; + } + + #[cfg(any(target_os = "macos", windows))] + { + let _ = self + .sender + .send(Message::DestroyWebviewHostWindow(self.webview_id)); + self.proxy.wake_up(); + return 1; + } + + #[cfg(not(any(target_os = "macos", windows)))] + 0 } fn on_before_close(&self, browser: Option<&mut Browser>) { - if browser.is_none() { + let Some(browser) = browser else { return; }; + if let Some(family) = self.popup_family.upgrade() { + family.closed(&self.frame_navigation_state, browser.identifier()); + } + if browser.is_popup() != 0 { + if self.opener.is_some() { + let _ = self.sender.send(Message::PopupClosed(browser.identifier())); + self.proxy.wake_up(); + } return; } - // Any permission prompt still open over this webview can no longer be - // granted to — deny it rather than leave the callback (and the app's - // consent UI) hanging over a dead browser. - crate::policy::cancel_pending(&self.webview_label); let _ = self .sender .send(Message::BrowserClosed(self.window_id, self.webview_id)); diff --git a/src/cef_impl/client/load.rs b/src/cef_impl/client/load.rs index 54d56df..d7ac7b9 100644 --- a/src/cef_impl/client/load.rs +++ b/src/cef_impl/client/load.rs @@ -8,30 +8,70 @@ use cef::*; wrap_load_handler! { pub struct TauriCefLoadHandler { - on_page_load_handler: Option>, + on_page_load_handler: Option>, + frame_event_handler: Option>, } impl LoadHandler { + fn on_loading_state_change( + &self, + browser: Option<&mut Browser>, + is_loading: ::std::os::raw::c_int, + _can_go_back: ::std::os::raw::c_int, + _can_go_forward: ::std::os::raw::c_int, + ) { + if let Some(browser) = browser { + let mut frame = browser.main_frame(); + crate::frame::emit_frame_event( + &self.frame_event_handler, + Some(browser), + frame.as_mut(), + crate::FrameEventKind::LoadingStateChanged { is_loading: is_loading != 0 }, + ); + } + } + fn on_load_start( &self, - _browser: Option<&mut Browser>, + browser: Option<&mut Browser>, frame: Option<&mut Frame>, _transition_type: TransitionType, ) { - let Some(handler) = &self.on_page_load_handler else { - return; - }; let Some(frame) = frame else { return; }; - - if frame.is_main() == 0 { - return; - } - let url = cef::CefString::from(&frame.url()).to_string(); if let Ok(url) = url::Url::parse(&url) { - handler(url, tauri_runtime::webview::PageLoadEvent::Started); + let is_main = frame.is_main() != 0; + crate::frame::emit_frame_event( + &self.frame_event_handler, + browser, + Some(frame), + crate::FrameEventKind::DocumentCommitted { url: url.clone() }, + ); + if is_main && let Some(handler) = &self.on_page_load_handler { + handler(url, tauri_runtime::webview::PageLoadEvent::Started); + } + } + } + + fn on_load_error( + &self, + browser: Option<&mut Browser>, + frame: Option<&mut Frame>, + _error_code: Errorcode, + _error_text: Option<&CefString>, + failed_url: Option<&CefString>, + ) { + if let Some(failed_url) = failed_url + && let Ok(url) = url::Url::parse(&failed_url.to_string()) + { + crate::frame::emit_frame_event( + &self.frame_event_handler, + browser, + frame, + crate::FrameEventKind::NavigationFailed { url }, + ); } } diff --git a/src/cef_impl/client/mod.rs b/src/cef_impl/client/mod.rs index b1993f2..ddcc9f9 100644 --- a/src/cef_impl/client/mod.rs +++ b/src/cef_impl/client/mod.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::sync::{Arc, Mutex, mpsc::Sender}; +use std::sync::{Arc, Mutex, Weak, mpsc::Sender}; use cef::*; use tauri_runtime::{UserEvent, window::WindowId}; @@ -10,13 +10,15 @@ use winit::event_loop::EventLoopProxy as WinitEventLoopProxy; use crate::{ cef_impl::{ipc, request_handler}, - runtime::{Message, RuntimeContext}, + macros::wrap_with_args, + runtime::{CefRuntime, Message, RuntimeContext}, }; mod context_menu; mod display; mod download; mod drag; +mod frame; mod keyboard; mod life_span; mod load; @@ -29,34 +31,36 @@ use download::TauriCefDownloadHandler; use drag::TauriCefDragHandler; pub(crate) use drag::{ DragDropEventTarget, DragDropScriptEvent, DragDropState, WebDragDropResourceRequestHandler, - drag_drop_initialization_script, event_from_script_event, + WebDragDropResourceRequestHandlerArgs, drag_drop_initialization_script, event_from_script_event, }; use keyboard::TauriCefKeyboardHandler; -use life_span::TauriCefChildLifeSpanHandler; +use life_span::{TauriCefChildLifeSpanHandler, TauriCefChildLifeSpanHandlerArgs}; use load::TauriCefLoadHandler; use permission::TauriCefPermissionHandler; pub(crate) use process::TauriCefBrowserProcessHandler; pub(crate) struct TauriCefBrowserClientHandlers { + pub(crate) frame_event_handler: Option>, pub(crate) ipc_handler: Option>>, - pub(crate) on_page_load_handler: Option>, + pub(crate) on_page_load_handler: Option>, pub(crate) document_title_changed_handler: - Option>, - pub(crate) navigation_handler: Option>, - pub(crate) address_changed_handler: Option>, - pub(crate) new_window_handler: Option>, - pub(crate) download_handler: Option>, - pub(crate) web_content_process_terminate_handler: Option>, + Option>, + pub(crate) navigation_handler: Option>, + pub(crate) new_window_handler: + Option>>>, + pub(crate) download_handler: Option>, + pub(crate) web_content_process_terminate_handler: + Option>, } impl Clone for TauriCefBrowserClientHandlers { fn clone(&self) -> Self { Self { + frame_event_handler: self.frame_event_handler.clone(), ipc_handler: self.ipc_handler.clone(), on_page_load_handler: self.on_page_load_handler.clone(), document_title_changed_handler: self.document_title_changed_handler.clone(), navigation_handler: self.navigation_handler.clone(), - address_changed_handler: self.address_changed_handler.clone(), new_window_handler: self.new_window_handler.clone(), download_handler: self.download_handler.clone(), web_content_process_terminate_handler: self.web_content_process_terminate_handler.clone(), @@ -64,7 +68,9 @@ impl Clone for TauriCefBrowserClientHandlers { } } -wrap_client! { +wrap_with_args! { + wrap_client => TauriCefBrowserClientArgs; + pub(crate) struct TauriCefBrowserClient { pub(crate) context: RuntimeContext, pub(crate) window_id: WindowId, @@ -75,12 +81,21 @@ wrap_client! { drag_drop_event_target: DragDropEventTarget, drag_drop_handler_enabled: bool, drag_drop_state: Arc>, + frame_navigation_state: crate::FrameNavigationState, + popup_family: Weak, + opener: Option, pub(crate) handlers: TauriCefBrowserClientHandlers, proxy: WinitEventLoopProxy, sender: Sender>, } impl Client { + fn frame_handler(&self) -> Option { + self.handlers.frame_event_handler.as_ref().map(|handler| { + frame::TauriCefFrameHandler::new(Some(handler.clone())) + }) + } + fn drag_handler(&self) -> Option { self .drag_drop_handler_enabled @@ -88,41 +103,96 @@ wrap_client! { } fn request_handler(&self) -> Option { - Some(request_handler::WebRequestHandler::new( - self.handlers.navigation_handler.clone(), - self.context.clone(), - self.window_id, - self.webview_id, - self.drag_drop_event_target, - self.drag_drop_handler_enabled, - self.drag_drop_state.clone(), - self.handlers.web_content_process_terminate_handler.clone(), + Some(request_handler::WebRequestHandler::build( + request_handler::WebRequestHandlerArgs { + navigation_handler: self.handlers.navigation_handler.clone(), + frame_event_handler: self.handlers.frame_event_handler.clone(), + context: self.context.clone(), + window_id: self.window_id, + webview_id: self.webview_id, + drag_drop_event_target: self.drag_drop_event_target, + drag_drop_handler_enabled: self.drag_drop_handler_enabled, + drag_drop_state: self.drag_drop_state.clone(), + web_content_process_terminate_handler: + self.handlers.web_content_process_terminate_handler.clone(), + }, )) } fn life_span_handler(&self) -> Option { - Some(TauriCefChildLifeSpanHandler::new( - self.sender.clone(), - self.proxy.clone(), - self.window_id, - self.webview_id, - self.label.clone(), - self.context.clone(), - self.handlers.new_window_handler.clone(), - self.initial_url.clone(), - )) + let context = self.context.clone(); + let window_id = self.window_id; + let webview_id = self.webview_id; + let label = self.label.clone(); + let devtools_enabled = self.devtools_enabled; + let target = self.drag_drop_event_target; + let navigation_handler = self.handlers.navigation_handler.clone(); + let new_window_handler = self.handlers.new_window_handler.clone(); + let download_handler = self.handlers.download_handler.clone(); + let family = self.popup_family.clone(); + let create_popup: Arc = Arc::new(move |opener, state| { + let events = state.clone(); + TauriCefBrowserClient::build(TauriCefBrowserClientArgs { + context: context.clone(), + window_id, + webview_id, + label: label.clone(), + initial_url: None, + devtools_enabled, + drag_drop_event_target: target, + drag_drop_handler_enabled: false, + drag_drop_state: Arc::default(), + frame_navigation_state: state, + popup_family: family.clone(), + opener: Some(opener), + handlers: TauriCefBrowserClientHandlers { + // Only the internal navigation observer, never the opener's app + // observer. A popup is a separate native browser that navigates + // wherever its own content goes — an SSO or OAuth window is the + // standing case — and every `FrameEvent` carries the full URL. An + // app observes popups without their URLs through `Webview::popups`. + frame_event_handler: Some(Arc::new(move |event| events.on_frame_event(&event))), + navigation_handler: navigation_handler.clone(), + new_window_handler: new_window_handler.clone(), + // CEF cancels every download of a client whose download handler is + // NULL, so the popup keeps the opener's — as it did when it still + // inherited the opener's client outright. + download_handler: download_handler.clone(), + ipc_handler: None, + on_page_load_handler: None, + document_title_changed_handler: None, + web_content_process_terminate_handler: None, + }, + proxy: context.proxy.clone(), + sender: context.sender.clone(), + }) + }); + Some(TauriCefChildLifeSpanHandler::build(TauriCefChildLifeSpanHandlerArgs { + sender: self.sender.clone(), + proxy: self.proxy.clone(), + window_id: self.window_id, + webview_id: self.webview_id, + context: self.context.clone(), + new_window_handler: self.handlers.new_window_handler.clone(), + initial_url: self.initial_url.clone(), + frame_navigation_state: self.frame_navigation_state.clone(), + popup_family: self.popup_family.clone(), + opener: self.opener.clone(), + create_popup, + })) } fn load_handler(&self) -> Option { Some(TauriCefLoadHandler::new( self.handlers.on_page_load_handler.clone(), + self.handlers.frame_event_handler.clone(), )) } fn display_handler(&self) -> Option { Some(TauriCefDisplayHandler::new( self.handlers.document_title_changed_handler.clone(), - self.handlers.address_changed_handler.clone(), + self.handlers.frame_event_handler.clone(), )) } @@ -143,16 +213,18 @@ wrap_client! { } fn permission_handler(&self) -> Option { - Some(TauriCefPermissionHandler::new(self.label.clone())) + Some(TauriCefPermissionHandler::new()) } fn on_process_message_received( &self, - _browser: Option<&mut Browser>, + browser: Option<&mut Browser>, frame: Option<&mut Frame>, source_process: ProcessId, message: Option<&mut ProcessMessage>, ) -> std::os::raw::c_int { + // A CEF popup (including DevTools) never inherits the root IPC identity. + if self.opener.is_some() || browser.as_ref().is_none_or(|browser| browser.is_popup() != 0 || !self.frame_navigation_state.has_browser_id(browser.identifier())) { return 0; } ipc::on_process_message_received(self, frame, source_process, message) } } diff --git a/src/cef_impl/client/permission.rs b/src/cef_impl/client/permission.rs index 6861444..f2ad40d 100644 --- a/src/cef_impl/client/permission.rs +++ b/src/cef_impl/client/permission.rs @@ -2,199 +2,259 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -//! Adapter from CEF's permission prompts to the runtime-neutral policy in -//! [`crate::policy`]. -//! -//! Media-access requests use the same policy as Chromium permission prompts. -//! -//! Grants are also written back as content settings. -//! `OnRequestMediaAccessPermission` bypasses Chromium's permission manager, so -//! otherwise `enumerateDevices` still sees "not granted": it hides device -//! labels and reports one placeholder per kind, and nothing is persisted. +use cef::sys::cef_permission_request_types_t as PermissionType; +use cef::*; -use cef::{rc::Rc as _, *}; +const AUDIO_CAPTURE: u32 = + cef::sys::cef_media_access_permission_types_t::CEF_MEDIA_PERMISSION_DEVICE_AUDIO_CAPTURE as u32; +const VIDEO_CAPTURE: u32 = + cef::sys::cef_media_access_permission_types_t::CEF_MEDIA_PERMISSION_DEVICE_VIDEO_CAPTURE as u32; -use crate::policy::{self, PermissionKind, RequestSource}; +/// Media capture permissions granted to Alloy style browsers. Desktop capture is +/// deliberately excluded. +const ALLOY_MEDIA_PERMISSIONS: u32 = AUDIO_CAPTURE | VIDEO_CAPTURE; + +/// The content setting that records each permission request type. +/// +/// Kept in sync with Chromium's `permissions::RequestTypeToContentSettingsType`, which +/// is what `cef_permission_request_types_t` mirrors. Request types with no content +/// setting of their own are absent and are simply not recorded. +const PERMISSION_CONTENT_SETTINGS: &[(u32, ContentSettingTypes)] = &[ + ( + PermissionType::CEF_PERMISSION_TYPE_AR_SESSION as u32, + ContentSettingTypes::AR, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_CAMERA_PAN_TILT_ZOOM as u32, + ContentSettingTypes::CAMERA_PAN_TILT_ZOOM, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_CAMERA_STREAM as u32, + ContentSettingTypes::MEDIASTREAM_CAMERA, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_CAPTURED_SURFACE_CONTROL as u32, + ContentSettingTypes::CAPTURED_SURFACE_CONTROL, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_CLIPBOARD as u32, + ContentSettingTypes::CLIPBOARD_READ_WRITE, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_TOP_LEVEL_STORAGE_ACCESS as u32, + ContentSettingTypes::TOP_LEVEL_STORAGE_ACCESS, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_DISK_QUOTA as u32, + ContentSettingTypes::PERSISTENT_STORAGE, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_LOCAL_FONTS as u32, + ContentSettingTypes::LOCAL_FONTS, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_GEOLOCATION as u32, + ContentSettingTypes::GEOLOCATION, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_HAND_TRACKING as u32, + ContentSettingTypes::HAND_TRACKING, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_IDENTITY_PROVIDER as u32, + ContentSettingTypes::FEDERATED_IDENTITY_API, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_IDLE_DETECTION as u32, + ContentSettingTypes::IDLE_DETECTION, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_MIC_STREAM as u32, + ContentSettingTypes::MEDIASTREAM_MIC, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_MIDI_SYSEX as u32, + ContentSettingTypes::MIDI_SYSEX, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_MULTIPLE_DOWNLOADS as u32, + ContentSettingTypes::AUTOMATIC_DOWNLOADS, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_NOTIFICATIONS as u32, + ContentSettingTypes::NOTIFICATIONS, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_KEYBOARD_LOCK as u32, + ContentSettingTypes::KEYBOARD_LOCK, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_POINTER_LOCK as u32, + ContentSettingTypes::POINTER_LOCK, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_PROTECTED_MEDIA_IDENTIFIER as u32, + ContentSettingTypes::PROTECTED_MEDIA_IDENTIFIER, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_REGISTER_PROTOCOL_HANDLER as u32, + ContentSettingTypes::PROTOCOL_HANDLERS, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_STORAGE_ACCESS as u32, + ContentSettingTypes::STORAGE_ACCESS, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_VR_SESSION as u32, + ContentSettingTypes::VR, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_WEB_APP_INSTALLATION as u32, + ContentSettingTypes::WEB_APP_INSTALLATION, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_WINDOW_MANAGEMENT as u32, + ContentSettingTypes::WINDOW_MANAGEMENT, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_FILE_SYSTEM_ACCESS as u32, + ContentSettingTypes::FILE_SYSTEM_WRITE_GUARD, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_LOCAL_NETWORK_ACCESS_DEPRECATED as u32, + ContentSettingTypes::LOCAL_NETWORK_ACCESS, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_LOCAL_NETWORK as u32, + ContentSettingTypes::LOCAL_NETWORK, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_LOOPBACK_NETWORK as u32, + ContentSettingTypes::LOOPBACK_NETWORK, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_SENSORS as u32, + ContentSettingTypes::SENSORS, + ), +]; wrap_permission_handler! { - pub struct TauriCefPermissionHandler { - webview_label: String, - } + pub struct TauriCefPermissionHandler {} impl PermissionHandler { fn on_request_media_access_permission( &self, browser: Option<&mut Browser>, - frame: Option<&mut Frame>, + _frame: Option<&mut Frame>, requesting_origin: Option<&CefString>, requested_permissions: u32, callback: Option<&mut MediaAccessCallback>, ) -> ::std::os::raw::c_int { - use cef::sys::cef_media_access_permission_types_t as bits; + // Chrome style displays the permission request UI and records the outcome as a + // content setting. That content setting is what keeps `enumerateDevices()` from + // returning a redacted device list, so let CEF handle the request. + let Some(host) = alloy_style_host(browser) else { + return 0; + }; + // Alloy style has no permission UI and its default handling denies the request, + // so grant camera and microphone capture here. let Some(callback) = callback else { return 0; }; - let callback = callback.clone(); - let origin = requesting_origin.map(|origin| origin.to_string()).unwrap_or_default(); - let is_main_frame = frame.map(|frame| frame.is_main() != 0); - let kinds = policy::media_kinds(requested_permissions); - - let request_context = browser - .and_then(|browser| browser.host()) - .and_then(|host| host.request_context()); - let content_types = media_content_types(&kinds); - - // A stored grant answers without asking the policy again. - if let Some(request_context) = request_context.as_ref() - && !content_types.is_empty() - && content_types.len() == kinds.len() - && content_types.iter().all(|content_type| { - is_allowed(request_context, &origin, *content_type) - }) - { - callback.cont(requested_permissions); - return 1; + + let allowed = requested_permissions & ALLOY_MEDIA_PERMISSIONS; + if allowed == 0 { + return 0; } - let grant_recorder = GrantRecorder { - request_context, - origin: origin.clone(), - content_types, - }; + let mut settings = Vec::with_capacity(2); + if allowed & AUDIO_CAPTURE != 0 { + settings.push(ContentSettingTypes::MEDIASTREAM_MIC); + } + if allowed & VIDEO_CAPTURE != 0 { + settings.push(ContentSettingTypes::MEDIASTREAM_CAMERA); + } + allow_content_settings(&host, requesting_origin, &settings); - policy::dispatch( - &self.webview_label, - &origin, - RequestSource::MediaAccess, - kinds, - is_main_frame, - move |granted| { - if granted { - grant_recorder.record(); - } - callback.cont(if granted { - requested_permissions - } else { - bits::CEF_MEDIA_PERMISSION_NONE as u32 - }); - }, - ); + callback.cont(allowed); 1 } fn on_show_permission_prompt( &self, - _browser: Option<&mut Browser>, + browser: Option<&mut Browser>, _prompt_id: u64, requesting_origin: Option<&CefString>, requested_permissions: u32, callback: Option<&mut PermissionPromptCallback>, ) -> ::std::os::raw::c_int { + // Chrome style displays the permission prompt UI. + let Some(host) = alloy_style_host(browser) else { + return 0; + }; + + // Alloy style has no prompt UI, and its default handling is + // `CEF_PERMISSION_RESULT_IGNORE`, which can leave the page's promise unresolved. + // Accept instead, matching the behavior Alloy browsers had before permission + // prompts were deferred to CEF. let Some(callback) = callback else { return 0; }; - let callback = callback.clone(); - let origin = requesting_origin.map(|origin| origin.to_string()).unwrap_or_default(); - policy::dispatch( - &self.webview_label, - &origin, - RequestSource::Prompt, - policy::prompt_kinds(requested_permissions), - // CEF reports no frame for permission prompts — they are browser-scoped. - None, - move |granted| { - let result = if granted { - cef::sys::cef_permission_request_result_t::CEF_PERMISSION_RESULT_ACCEPT - } else { - cef::sys::cef_permission_request_result_t::CEF_PERMISSION_RESULT_DENY - }; - callback.cont(PermissionRequestResult::from(result)); - }, - ); + + let settings = PERMISSION_CONTENT_SETTINGS + .iter() + .filter(|(permission, _)| requested_permissions & permission != 0) + .map(|(_, setting)| *setting) + .collect::>(); + allow_content_settings(&host, requesting_origin, &settings); + + callback.cont(PermissionRequestResult::ACCEPT); 1 } } } -/// `getDisplayMedia` is left out: Chromium asks per use, so nothing persists. -fn media_content_types(kinds: &[PermissionKind]) -> Vec { - kinds - .iter() - .filter_map(|kind| match kind { - PermissionKind::Microphone => Some(ContentSettingTypes::MEDIASTREAM_MIC), - PermissionKind::Camera => Some(ContentSettingTypes::MEDIASTREAM_CAMERA), - _ => None, - }) - .collect() -} - -fn is_allowed( - request_context: &RequestContext, - origin: &str, - content_type: ContentSettingTypes, -) -> bool { - let origin = CefString::from(origin); - request_context.content_setting(Some(&origin), Some(&origin), content_type) - == ContentSettingValues::ALLOW +/// The host of `browser` when it uses the Alloy runtime style, which provides no +/// permission UI. +/// +/// Returns `None` when the style cannot be determined, so that permission handling is +/// deferred to CEF, which is correct for the Chrome style Tauri webviews use by default. +fn alloy_style_host(browser: Option<&mut Browser>) -> Option { + browser + .and_then(|browser| browser.host()) + .filter(|host| host.runtime_style() == RuntimeStyle::ALLOY) } -/// `SetContentSetting` is UI-thread only; the policy may answer from any thread. -struct GrantRecorder { - request_context: Option, - origin: String, - content_types: Vec, -} - -impl GrantRecorder { - fn record(&self) { - let Some(request_context) = self.request_context.as_ref() else { - return; - }; - if self.content_types.is_empty() || self.origin.is_empty() { - return; - } - - if cef::currently_on(cef::sys::cef_thread_id_t::TID_UI.into()) != 0 { - record_grant(request_context, &self.origin, &self.content_types); - return; - } - - let mut task = RecordGrantTask::new( - request_context.clone(), - self.origin.clone(), - self.content_types.clone(), - ); - cef::post_task(cef::sys::cef_thread_id_t::TID_UI.into(), Some(&mut task)); - } -} - -fn record_grant( - request_context: &RequestContext, - origin: &str, - content_types: &[ContentSettingTypes], +/// Records permissions granted to an Alloy style browser as content settings for +/// `requesting_origin`. +/// +/// Granting through a CEF callback alone is invisible to Chromium's permission layer, so +/// `navigator.permissions.query()` keeps reporting `prompt` even though the feature +/// works. Writing the content setting is what Chrome style does when the user accepts +/// its prompt. +/// +/// Does nothing when the origin is unknown, because `set_content_setting` with no URL +/// changes the default for every origin rather than for this one. +fn allow_content_settings( + host: &BrowserHost, + requesting_origin: Option<&CefString>, + settings: &[ContentSettingTypes], ) { - let origin = CefString::from(origin); - for content_type in content_types { - request_context.set_content_setting( - Some(&origin), - Some(&origin), - *content_type, - ContentSettingValues::ALLOW, - ); + if requesting_origin.is_none() || settings.is_empty() { + return; } -} -wrap_task! { - struct RecordGrantTask { - request_context: RequestContext, - origin: String, - content_types: Vec, - } + let Some(context) = host.request_context() else { + return; + }; - impl Task { - fn execute(&self) { - record_grant(&self.request_context, &self.origin, &self.content_types); - } + for setting in settings { + context.set_content_setting( + requesting_origin, + None, + *setting, + ContentSettingValues::ALLOW, + ); } } diff --git a/src/cef_impl/ipc.rs b/src/cef_impl/ipc.rs index 9a57c6d..3a477a6 100644 --- a/src/cef_impl/ipc.rs +++ b/src/cef_impl/ipc.rs @@ -152,38 +152,17 @@ pub(crate) fn on_process_message_received( let body = CefString::from(&args.string(1)).to_string(); if let Ok(request) = http::Request::builder().uri(url).body(body) { - let webview = DetachedWebview { - label: client.label.clone(), - dispatcher: CefWebviewDispatcher { - window_id: Arc::new(Mutex::new(client.window_id)), - webview_id: client.webview_id, - context: client.context.clone(), + handler( + DetachedWebview { + label: client.label.clone(), + dispatcher: CefWebviewDispatcher { + window_id: Arc::new(Mutex::new(client.window_id)), + webview_id: client.webview_id, + context: client.context.clone(), + }, }, - }; - // Run the handler through the event loop instead of inside this CEF - // callout. A sync tauri command that round-trips the loop (window - // creation, blocking getters) would otherwise self-deadlock whenever this - // callout runs on the main thread OUTSIDE a winit callback — no current - // dispatch is installed, so the round-trip queues a message the parked - // loop can never drain. That is the steady state on macOS, where CEF work - // is pumped from NSRunLoop timer callouts (huddle pop-out froze the whole - // browser process). Where a dispatch IS installed (Linux services CEF via - // glib inside winit callbacks), send_message degenerates to the same - // inline call as before. - // - // ThreadSafe: the handler Arc is not Sync, but it never actually crosses - // threads — this callout runs on the CEF UI thread (the runtime main - // thread), and Message::Task closures execute on that same thread. - let handler = crate::cef_impl::request_handler::ThreadSafe(handler.clone()); - if let Err(error) = client - .context - .send_message(crate::runtime::Message::Task(Box::new(move || { - (handler.into_owned())(webview, request); - }))) - { - // Only fails when the loop is gone (shutdown) — the invoke is moot then. - log::debug!("dropped webview IPC message: {error}"); - } + request, + ); } 1 } diff --git a/src/cef_impl/request_context.rs b/src/cef_impl/request_context.rs index 608ddf6..3a15c38 100644 --- a/src/cef_impl/request_context.rs +++ b/src/cef_impl/request_context.rs @@ -180,10 +180,10 @@ pub(crate) fn wait_for_deferred_init(flag: &Arc) { /// [`wait_for_deferred_init`] on this thread toggles the flag, which makes /// nesting (e.g. an `on_initialized` continuation that creates another /// webview) safe. -pub(crate) struct AllowNestableTasks; +struct AllowNestableTasks; impl AllowNestableTasks { - pub(crate) fn enter() -> Self { + fn enter() -> Self { NESTABLE_TASKS_DEPTH.with(|depth| { let current = depth.get(); if current == 0 { @@ -328,14 +328,6 @@ pub(crate) fn request_context_from_webview_attributes<'a>( let settings = RequestContextSettings { cache_path, - // Per-context settings do not inherit the global value, so an empty list - // here would silently drop custom-scheme cookie support configured - // through `CefConfig::cookieable_schemes`. - cookieable_schemes_list: crate::config::config() - .cookieable_schemes - .join(",") - .as_str() - .into(), ..Default::default() }; @@ -366,7 +358,6 @@ pub(crate) fn request_context_from_webview_attributes<'a>( if let Some(request_context) = request_context.as_ref() { for scheme in custom_schemes { - // Windows/Android-style form: `http(s)://.localhost/…`. request_context.register_scheme_handler_factory( Some(&custom_protocol_scheme.into()), Some(&format!("{scheme}.localhost").as_str().into()), @@ -375,18 +366,6 @@ pub(crate) fn request_context_from_webview_attributes<'a>( scheme.clone(), )), ); - // Native form published tauri emits on Linux/macOS: - // `://localhost/…`. The scheme itself is made known to - // Chromium in `on_register_custom_schemes` (crate config list); an - // empty domain filter matches every host on the scheme. - request_context.register_scheme_handler_factory( - Some(&scheme.as_str().into()), - None, - Some(&mut request_handler::UriSchemeHandlerFactory::new( - scheme_registry.clone(), - scheme.clone(), - )), - ); } } diff --git a/src/cef_impl/request_handler.rs b/src/cef_impl/request_handler.rs index 28e6ec1..5b0f348 100644 --- a/src/cef_impl/request_handler.rs +++ b/src/cef_impl/request_handler.rs @@ -15,10 +15,12 @@ use http::{ HeaderMap, HeaderName, HeaderValue, header::{CONTENT_SECURITY_POLICY, CONTENT_TYPE, ORIGIN}, }; -use kuchiki::NodeRef; -use tauri_runtime::{UserEvent, window::WindowId}; - -use crate::compat::{NavigationHandler, UriSchemeProtocolHandler}; +use kuchiki::{Attribute, ExpandedName, NodeRef}; +use tauri_runtime::{ + UserEvent, + webview::{NavigationHandler, UriSchemeProtocolHandler}, + window::WindowId, +}; use tauri_utils::{ config::{Csp, CspDirectiveSources}, html::{parse as parse_html, serialize_node}, @@ -26,26 +28,16 @@ use tauri_utils::{ use url::Url; use crate::{ - cef_impl::client::{DragDropEventTarget, DragDropState, WebDragDropResourceRequestHandler}, + cef_impl::client::{ + DragDropEventTarget, DragDropState, WebDragDropResourceRequestHandler, + WebDragDropResourceRequestHandlerArgs, + }, + macros::wrap_with_args, runtime::RuntimeContext, - streaming::{self, InitiatorOrigin, ReadOutcome, StreamBody}, webview::{CefInitScript, INITIAL_LOAD_URL}, }; type HttpResponse = Arc>>>>>; - -/// The pull side of a streaming custom-scheme response, installed by -/// `process_request` when the request's scheme has a streaming handler. `head` -/// is the shared slot the handler's `StreamResponder` publishes status + -/// headers into; `body` is drained by `read()`. Wrapped in `Arc` (not -/// the buffered path's `RefCell`) because the producer thread's wake closure -/// re-enters `body` to deliver chunks asynchronously. -type StreamCell = Arc>>; - -struct StreamState { - head: Arc>>>, - body: StreamBody, -} pub(crate) type SchemeRegistry = Arc< Mutex< std::collections::HashMap< @@ -62,6 +54,7 @@ pub(crate) type SchemeRegistry = Arc< fn csp_inject_initialization_scripts_hashes( existing_csp: String, initialization_scripts: &[CefInitScript], + is_main_frame: bool, ) -> String { if initialization_scripts.is_empty() { return existing_csp; @@ -69,6 +62,7 @@ fn csp_inject_initialization_scripts_hashes( let script_hashes: Vec = initialization_scripts .iter() + .filter(|script| script.runs_in_frame(is_main_frame)) .map(|s| s.hash.clone()) .collect(); @@ -91,6 +85,7 @@ fn csp_inject_initialization_scripts_hashes( fn inject_scripts_into_html_body( body: &[u8], initialization_scripts: &[CefInitScript], + is_main_frame: bool, ) -> Option> { let Ok(body_str) = std::str::from_utf8(body) else { return None; @@ -109,43 +104,101 @@ fn inject_scripts_into_html_body( head_node }; - for init_script in initialization_scripts.iter().rev() { + for init_script in initialization_scripts + .iter() + .rev() + .filter(|script| script.runs_in_frame(is_main_frame)) + { let script_el = NodeRef::new_element(QualName::new(None, ns!(html), "script".into()), None); script_el.append(NodeRef::new_text(init_script.script.as_str())); head.prepend(script_el); } + keep_encoding_declaration_first(&document, &head); + Some(serialize_node(&document)) } -wrap_request_handler! { +/// Keeps the document's character encoding declaration ahead of the injected scripts. +/// +/// Chromium only pre-scans the first 1024 bytes of a document for a `` +/// declaration. The initialization scripts we prepend to `` are much larger than +/// that, so an encoding declaration that used to be in the pre-scan window ends up out +/// of reach and the document is decoded with the fallback encoding (windows-1252) +/// instead. That mojibakes every non-ASCII byte in the page and in the injected scripts, +/// which additionally breaks the CSP hashes we compute over the UTF-8 source, so the +/// browser refuses to execute the affected script. +fn keep_encoding_declaration_first(document: &NodeRef, head: &NodeRef) { + let existing_declaration = document.select("meta").ok().and_then(|metas| { + metas.into_iter().find(|meta| { + let attributes = meta.attributes.borrow(); + attributes.get("charset").is_some() + || attributes + .get("http-equiv") + .map(|value| value.trim().eq_ignore_ascii_case("content-type")) + .unwrap_or(false) + }) + }); + + match existing_declaration { + // the document declares its encoding, move that declaration back into the pre-scan window + Some(declaration) => { + let node = declaration.as_node().clone(); + node.detach(); + head.prepend(node); + } + // no declaration at all: the assets are served as UTF-8 (this handler parses them as + // such), so make that explicit instead of leaving it to the fallback encoding + None => head.prepend(NodeRef::new_element( + QualName::new(None, ns!(html), LocalName::from("meta")), + [( + ExpandedName::new(ns!(), LocalName::from("charset")), + Attribute { + prefix: None, + value: "utf-8".into(), + }, + )], + )), + } +} + +wrap_with_args! { + wrap_request_handler => WebRequestHandlerArgs; + pub struct WebRequestHandler { navigation_handler: Option>, + frame_event_handler: Option>, context: RuntimeContext, window_id: WindowId, webview_id: u32, drag_drop_event_target: DragDropEventTarget, drag_drop_handler_enabled: bool, drag_drop_state: Arc>, - web_content_process_terminate_handler: Option>, + web_content_process_terminate_handler: Option>, } impl RequestHandler { fn on_render_process_terminated( &self, - _browser: Option<&mut Browser>, - _status: TerminationStatus, - _error_code: ::std::os::raw::c_int, - _error_string: Option<&CefString>, + browser: Option<&mut Browser>, + status: TerminationStatus, + error_code: ::std::os::raw::c_int, + error_string: Option<&CefString>, ) { + let mut frame = browser.as_ref().and_then(|browser| browser.main_frame()); + crate::frame::emit_frame_event(&self.frame_event_handler, browser, frame.as_mut(), crate::FrameEventKind::RendererTerminated); if let Some(handler) = &self.web_content_process_terminate_handler { - handler(); + handler(tauri_runtime::webview::WebContentProcessTermination { + reason: termination_reason(status), + error_code: Some(error_code), + error_string: error_string.map(ToString::to_string), + }); } } fn on_before_browse( &self, - _browser: Option<&mut Browser>, + browser: Option<&mut Browser>, frame: Option<&mut Frame>, request: Option<&mut Request>, _user_gesture: ::std::os::raw::c_int, @@ -156,10 +209,6 @@ wrap_request_handler! { let Some(frame) = frame else { return 0; }; - // we only fire main frame navigation events to match the behavior of the wry runtime - if frame.is_main() == 0 { - return 0; - } let Some(request) = request else { return 0; }; @@ -174,12 +223,20 @@ wrap_request_handler! { return 0; }; - let Some(handler) = &self.navigation_handler else { - return 0; - }; - - let should_navigate = handler(&url); - if should_navigate { 0 } else { 1 } + // Preserve the portable main-frame policy. Native observers receive only + // admitted navigations, so a denied navigation cannot strand their barrier. + if frame.is_main() != 0 + && self.navigation_handler.as_ref().is_some_and(|handler| !handler(&url)) + { + return 1; + } + crate::frame::emit_frame_event( + &self.frame_event_handler, + browser, + Some(frame), + crate::FrameEventKind::NavigationStarted { url }, + ); + 0 } fn resource_request_handler( @@ -200,72 +257,32 @@ wrap_request_handler! { return None; } - Some(WebDragDropResourceRequestHandler::new( - self.context.clone(), - self.window_id, - self.webview_id, - self.drag_drop_event_target, - self.drag_drop_handler_enabled, - self.drag_drop_state.clone(), + Some(WebDragDropResourceRequestHandler::build( + WebDragDropResourceRequestHandlerArgs { + context: self.context.clone(), + window_id: self.window_id, + webview_id: self.webview_id, + drag_drop_event_target: self.drag_drop_event_target, + drag_drop_handler_enabled: self.drag_drop_handler_enabled, + drag_drop_state: self.drag_drop_state.clone(), + }, )) } } } -/// Copy an `http` head onto CEF's `Response`, set `Cache-Control: no-store`, -/// derive the MIME from `Content-Type`, and mark the body length unknown -/// (`-1`). Shared by the buffered and streaming `response_headers` paths, which -/// differ only in the head's body type. -fn write_response_headers( - cef_response: &mut Response, - head: &http::Response, - response_length: Option<&mut i64>, - redirect_url: Option<&mut CefString>, -) { - cef_response.set_status(head.status().as_u16() as i32); - let mut content_type = None; - - // Apply via a multimap so REPEATED header names survive — `Set-Cookie` is - // the common one, and a page that sets two cookies in a single response must - // keep both. `set_header_by_name(.., overwrite=0)` per value silently drops - // the second (it only sets when the name is absent), so build the whole map - // and set it once. `http::HeaderMap`'s iterator yields (name, value) for - // every value, so duplicates come through naturally. - let mut map = CefStringMultimap::new(); - for (name, value) in head.headers() { - let Ok(value) = value.to_str() else { - continue; - }; - map.append(name.as_str(), value); - if name == CONTENT_TYPE { - content_type.replace(value.to_string()); - } - } - cef_response.set_header_map(Some(&mut map)); - - cef_response.set_header_by_name(Some(&"Cache-Control".into()), Some(&"no-store".into()), 1); - - let mime_type = content_type - .as_ref() - .and_then(|t| t.split(';').next()) - .map(str::trim) - .unwrap_or("text/plain"); - cef_response.set_mime_type(Some(&mime_type.into())); - - if let Some(length) = response_length { - *length = -1; - } - - if let Some(redirect_url) = redirect_url { - let _ = std::mem::take(redirect_url); - } -} +wrap_with_args! { + wrap_resource_handler => WebResourceHandlerArgs; -wrap_resource_handler! { pub struct WebResourceHandler { webview_label: String, handler: Arc>, initialization_scripts: Arc>, + // Whether the document this response is loaded into is the main frame. Scripts flagged + // `for_main_frame_only` are skipped for subframes - the isolation iframe most notably - + // matching both the wry runtime and the guard in the document-start script we register + // over CDP for documents that are not served by a custom protocol. + is_main_frame: bool, // Serialized origin of the main frame that initiated this request, captured // browser-side in the scheme handler factory. The renderer can issue an IPC // request before its execution context is fully wired to the loader; in @@ -276,9 +293,6 @@ wrap_resource_handler! { initiator_origin: Option, // we clone response to send it to the handler thread response: HttpResponse, - // Set only when this request's scheme has a registered streaming handler; - // `response` stays empty in that case and the two paths never mix. - stream: StreamCell, } impl ResourceHandler { @@ -293,50 +307,74 @@ wrap_resource_handler! { let url = CefString::from(&request.url()).to_string(); let url = Url::parse(&url).ok(); - let Some(url) = url else { return 0 }; - let scheme = url.scheme().to_string(); - - // Extraction shared by both paths — reads `request` before it is dropped. - let label = self.webview_label.clone(); - let data = read_request_body(request); - let mut headers = get_request_headers(request); - - // The renderer can issue an IPC request before its execution context is - // fully wired to the loader; in that window Chromium sends the request - // with `Origin: null` even though the document already has a real - // origin. Repair that from the initiating main frame's URL, which the - // browser process tracks reliably. - // - // ONLY a literal `null` is repaired — an ABSENT `Origin` is left absent. - // Absence is meaningful: a top-level navigation and a same-origin GET - // carry no `Origin` by design, and inventing one turns a navigation into - // what looks like a cross-origin call from the *previous* page — which a - // server's origin check then rightly refuses. A correct renderer-sent - // origin always wins. - if let Some(initiator_origin) = &self.initiator_origin - && headers - .get(ORIGIN) - .is_some_and(|value| value.as_bytes() == b"null") - && let Ok(value) = HeaderValue::from_str(initiator_origin) - { - headers.insert(ORIGIN, value); - } + if let Some(url) = url { + let callback = ThreadSafe(callback.clone()); + let response_store = ThreadSafe(self.response.clone()); + let initialization_scripts = self.initialization_scripts.clone(); + let is_main_frame = self.is_main_frame; + let responder = Box::new(move |response: http::Response>| { + let is_html = response + .headers() + .get(CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .map(|ct| ct.to_lowercase().starts_with("text/html")) + .unwrap_or(false); + + let (parts, body) = response.into_parts(); + let body_bytes = body.into_owned(); + let body_bytes = if is_html { + inject_scripts_into_html_body(&body_bytes, &initialization_scripts, is_main_frame) + .unwrap_or(body_bytes) + } else { + body_bytes + }; + + let mut response = http::Response::from_parts(parts, Cursor::new(body_bytes)); + + if let Some(csp) = response.headers_mut().get_mut(CONTENT_SECURITY_POLICY) { + let csp_string = csp.to_str().unwrap_or_default().to_string(); + let new_csp = csp_inject_initialization_scripts_hashes( + csp_string, + &initialization_scripts, + is_main_frame, + ); + if let Ok(new_csp) = HeaderValue::from_str(&new_csp) { + *csp = new_csp; + } + } + + response_store.into_owned().borrow_mut().replace(response); + + let callback = callback.into_owned(); + callback.cont(); + }); + + let label = self.webview_label.clone(); + let handler = self.handler.clone(); + + let data = read_request_body(request); + let mut headers = get_request_headers(request); + + // The renderer can issue an IPC request before its execution context is + // fully wired to the loader; in that window Chromium sends the request + // with `Origin: null` even though the document already has a real + // origin. Repair it from the initiating main frame's URL, which the + // browser process tracks reliably. Only done when the renderer sent no + // origin or a literal `null`, so a correct renderer-sent origin always + // wins. + if let Some(initiator_origin) = &self.initiator_origin { + let origin_missing_or_null = headers + .get(ORIGIN) + .map(|value| value.as_bytes() == b"null") + .unwrap_or(true); + if origin_missing_or_null && let Ok(value) = HeaderValue::from_str(initiator_origin) { + headers.insert(ORIGIN, value); + } + } + + let method_str = CefString::from(&request.method()).to_string(); + let method = http::Method::from_bytes(method_str.as_bytes()).unwrap_or(http::Method::GET); - let method_str = CefString::from(&request.method()).to_string(); - let method = http::Method::from_bytes(method_str.as_bytes()).unwrap_or(http::Method::GET); - - // Streaming path: the scheme registered a streaming handler. The handler - // publishes the head (which fires `callback.cont()`), then writes body - // chunks that `read()` drains. Init-script HTML injection is intentionally - // skipped — streaming bodies are never buffered or parsed. - if let Some(stream_handler) = streaming::streaming_handler_for(&scheme) { - let on_head = ThreadSafe(callback.clone()); - let (responder, head, body) = streaming::make_stream(Box::new(move || { - on_head.into_owned().cont(); - })); - *self.stream.lock().expect("stream slot poisoned") = Some(StreamState { head, body }); - - let initiator = InitiatorOrigin(self.initiator_origin.clone()); std::thread::spawn(move || { let mut http_request = http::Request::builder() .method(method) @@ -344,136 +382,25 @@ wrap_resource_handler! { .body(data) .unwrap(); *http_request.headers_mut() = headers; - http_request.extensions_mut().insert(initiator); - stream_handler(&label, http_request, responder); + // handler is Arc>, so we need to dereference to call it + (**handler)(&label, http_request, responder); }); - return 1; + 1 + } else { + 0 } - - // Buffered path: tauri's uri-scheme protocol handler produces one whole - // response, which `read()` streams out of a `Cursor`. - let callback = ThreadSafe(callback.clone()); - let response_store = ThreadSafe(self.response.clone()); - let initialization_scripts = self.initialization_scripts.clone(); - let responder = Box::new(move |response: http::Response>| { - let is_html = response - .headers() - .get(CONTENT_TYPE) - .and_then(|ct| ct.to_str().ok()) - .map(|ct| ct.to_lowercase().starts_with("text/html")) - .unwrap_or(false); - - let (parts, body) = response.into_parts(); - let body_bytes = body.into_owned(); - let body_bytes = if is_html { - inject_scripts_into_html_body(&body_bytes, &initialization_scripts).unwrap_or(body_bytes) - } else { - body_bytes - }; - - let mut response = http::Response::from_parts(parts, Cursor::new(body_bytes)); - - if let Some(csp) = response.headers_mut().get_mut(CONTENT_SECURITY_POLICY) { - let csp_string = csp.to_str().unwrap_or_default().to_string(); - let new_csp = - csp_inject_initialization_scripts_hashes(csp_string, &initialization_scripts); - if let Ok(new_csp) = HeaderValue::from_str(&new_csp) { - *csp = new_csp; - } - } - - response_store.into_owned().borrow_mut().replace(response); - - let callback = callback.into_owned(); - callback.cont(); - }); - - let handler = self.handler.clone(); - std::thread::spawn(move || { - let mut http_request = http::Request::builder() - .method(method) - .uri(url.as_str()) - .body(data) - .unwrap(); - *http_request.headers_mut() = headers; - // handler is Arc>, so we need to dereference to call it - (**handler)(&label, http_request, responder); - }); - 1 } - #[allow(clippy::not_unsafe_ptr_arg_deref)] fn read( &self, data_out: *mut u8, bytes_to_read: ::std::os::raw::c_int, bytes_read: Option<&mut ::std::os::raw::c_int>, - callback: Option<&mut ResourceReadCallback>, + _callback: Option<&mut ResourceReadCallback>, ) -> ::std::os::raw::c_int { let Ok(bytes_to_read) = usize::try_from(bytes_to_read) else { return 0; }; - - // Streaming path: drive the StreamBody. When a chunk is buffered we copy - // it synchronously; when the producer has not written yet we retain - // `data_out` + `callback`, park a wake, and return continue-with-0 — CEF's - // async read contract. The wake (fired by the next `StreamWriter::write` - // or writer drop, on the producer thread) copies the chunk into the - // retained buffer and calls `callback.cont(n)`; `cont(0)` signals EOF. - { - let mut guard = self.stream.lock().expect("stream slot poisoned"); - if let Some(state) = guard.as_mut() { - if bytes_to_read == 0 { - if let Some(bytes_read) = bytes_read { - *bytes_read = 0; - } - return 1; - } - let out = unsafe { std::slice::from_raw_parts_mut(data_out, bytes_to_read) }; - let stream = self.stream.clone(); - let callback = callback.map(|callback| ThreadSafe(callback.clone())); - let retained = ThreadSafe((data_out, bytes_to_read)); - let wake = move || { - let (ptr, len) = retained.into_owned(); - let out = unsafe { std::slice::from_raw_parts_mut(ptr, len) }; - let mut guard = stream.lock().expect("stream slot poisoned"); - let count = match guard.as_mut() { - // The wake only fires after a real write or the writer's drop, so - // `Pending` cannot occur here; treat it (and `Done`) as EOF. - Some(state) => match state.body.read(out, || {}) { - ReadOutcome::Copied(count) => count as ::std::os::raw::c_int, - ReadOutcome::Pending | ReadOutcome::Done => 0, - }, - None => 0, - }; - if let Some(callback) = callback { - callback.into_owned().cont(count); - } - }; - return match state.body.read(out, wake) { - ReadOutcome::Copied(count) => { - if let Some(bytes_read) = bytes_read { - *bytes_read = count as ::std::os::raw::c_int; - } - 1 - } - ReadOutcome::Pending => { - if let Some(bytes_read) = bytes_read { - *bytes_read = 0; - } - 1 - } - ReadOutcome::Done => { - if let Some(bytes_read) = bytes_read { - *bytes_read = 0; - } - 0 - } - }; - } - } - - // Buffered path: copy out of the response `Cursor`. let data_out = unsafe { std::slice::from_raw_parts_mut(data_out, bytes_to_read) }; let count = self .response @@ -499,28 +426,42 @@ wrap_resource_handler! { response_length: Option<&mut i64>, redirect_url: Option<&mut CefString>, ) { - let Some(response) = response else { + let (Some(response), Some(response_data)) = (response, &*self.response.borrow()) else { return; }; - // Streaming path: publish the head the `StreamResponder` stored. By the - // time CEF calls this, the handler has already fired `callback.cont()`, so - // the head slot is populated. - { - let guard = self.stream.lock().expect("stream slot poisoned"); - if let Some(state) = guard.as_ref() { - if let Some(head) = state.head.lock().expect("stream head poisoned").as_ref() { - write_response_headers(response, head, response_length, redirect_url); - } - return; + response.set_status(response_data.status().as_u16() as i32); + let mut content_type = None; + + // Set response headers and remember the MIME type for CEF. + for (name, value) in response_data.headers() { + let Ok(value) = value.to_str() else { + continue; + }; + + response.set_header_by_name(Some(&name.as_str().into()), Some(&value.into()), 0); + + if name == CONTENT_TYPE { + content_type.replace(value.to_string()); } } - // Buffered path. - let Some(response_data) = &*self.response.borrow() else { - return; - }; - write_response_headers(response, response_data, response_length, redirect_url); + response.set_header_by_name(Some(&"Cache-Control".into()), Some(&"no-store".into()), 1); + + let mime_type = content_type + .as_ref() + .and_then(|t| t.split(';').next()) + .map(str::trim) + .unwrap_or("text/plain"); + response.set_mime_type(Some(&mime_type.into())); + + if let Some(length) = response_length { + *length = -1; + } + + if let Some(redirect_url) = redirect_url { + let _ = std::mem::take(redirect_url); + } } } } @@ -539,16 +480,46 @@ wrap_scheme_handler_factory! { _scheme_name: Option<&CefString>, _request: Option<&mut Request>, ) -> Option { - let browser = browser?; - let id = browser.identifier(); + let (webview_label, handler, initialization_scripts) = match browser { + Some(browser) => { + let id = browser.identifier(); + + // get handler from our regsitry based on browser ID and scheme + self + .registry + .lock() + .unwrap() + .get(&(id, self.scheme.clone())) + .cloned()? + } + // `browser`/`frame` are null for requests that do not originate from a + // browser: service worker (main script and update) fetches and + // CefURLRequest. Returning `None` here would fall through to the + // network service, where `{scheme}.localhost` resolves to loopback and + // the connection is refused — which is exactly how service worker + // registration on a custom-protocol origin used to fail with "An + // unknown error occurred when fetching the script.". + // + // Every registry entry for a given scheme wraps the same app-level + // `UriSchemeProtocolHandler`, so any live entry can serve the request; + // only the webview label differs, and asset serving does not depend on + // it. Initialization scripts are per-document injections, meaningless + // without a browser, so they are not applied on this path. + None => { + let (webview_label, handler, _) = self + .registry + .lock() + .unwrap() + .iter() + .find(|((_, scheme), _)| *scheme == self.scheme) + .map(|(_, entry)| entry.clone())?; + (webview_label, handler, Arc::new(Vec::new())) + } + }; - // get handler from our regsitry based on browser ID and scheme - let (webview_label, handler, initialization_scripts) = self - .registry - .lock() - .unwrap() - .get(&(id, self.scheme.clone())) - .cloned()?; + // A subframe navigation is the only case we can positively identify here, so anything + // else (a null frame included) keeps the main frame behavior of injecting everything. + let is_main_frame = frame.as_ref().map(|frame| frame.is_main() == 1).unwrap_or(true); // Capture the initiating main frame's origin so `process_request` can // repair a racy `Origin: null` header. Restricted to the main frame: it @@ -558,24 +529,25 @@ wrap_scheme_handler_factory! { .filter(|frame| frame.is_main() == 1) .map(|frame| CefString::from(&frame.url()).to_string()) .and_then(|url| Url::parse(&url).ok()) - .and_then(|url| tuple_origin(&url)); + .map(|url| url.origin().ascii_serialization()) + .filter(|origin| origin != "null"); - Some(WebResourceHandler::new( + Some(WebResourceHandler::build(WebResourceHandlerArgs { webview_label, handler, initialization_scripts, + is_main_frame, initiator_origin, - Arc::new(RefCell::new(None)), - Arc::new(Mutex::new(None)), - )) + response: Arc::new(RefCell::new(None)), + })) } } } -pub(crate) struct ThreadSafe(pub(crate) T); +struct ThreadSafe(T); impl ThreadSafe { - pub(crate) fn into_owned(self) -> T { + fn into_owned(self) -> T { self.0 } } @@ -623,56 +595,6 @@ fn read_request_body(request: &mut Request) -> Vec { body } -/// The tuple origin of `url`, as **Chromium** serializes it. -/// -/// `Url::origin()` implements the URL spec, where only *special* schemes -/// (http, https, ws, wss, ftp, file) get a tuple origin and everything else is -/// opaque — serialized `"null"`. But a custom scheme registered with -/// `CEF_SCHEME_OPTION_STANDARD` (which is every scheme in -/// `CefConfig::custom_schemes`, see `register_tauri_schemes`) DOES get a real -/// `scheme://host[:port]` tuple origin inside Chromium, and that is the origin -/// the renderer actually enforces (CSP, CORS) — and the one a scheme handler -/// must compare against. Composing it by hand for the opaque case is what -/// makes `InitiatorOrigin` usable on custom schemes at all; without this it is -/// always `None` there, silently disabling both the `Origin: null` repair in -/// `process_request` and any same-origin check a handler builds on it. -fn tuple_origin(url: &Url) -> Option { - let spec_origin = url.origin().ascii_serialization(); - if spec_origin != "null" { - return Some(spec_origin); - } - let host = url.host_str()?; - Some(match url.port() { - Some(port) => format!("{}://{}:{}", url.scheme(), host, port), - None => format!("{}://{}", url.scheme(), host), - }) -} - -#[cfg(test)] -mod tests { - use super::tuple_origin; - use url::Url; - - #[test] - fn tuple_origin_covers_special_and_custom_schemes() { - let special = Url::parse("https://example.com:8443/x?y").unwrap(); - assert_eq!( - tuple_origin(&special).as_deref(), - Some("https://example.com:8443") - ); - // A standard-registered custom scheme: the URL spec calls this opaque, but - // Chromium gives it a tuple origin, so we must too. - let custom = Url::parse("duck://site.alice.duck/index.html").unwrap(); - assert_eq!( - tuple_origin(&custom).as_deref(), - Some("duck://site.alice.duck") - ); - // Genuinely origin-less: nothing to compare against, so no origin. - let opaque = Url::parse("data:text/html,hi").unwrap(); - assert_eq!(tuple_origin(&opaque), None); - } -} - fn get_request_headers(request: &mut Request) -> HeaderMap { let mut headers = HeaderMap::new(); @@ -692,3 +614,44 @@ fn get_request_headers(request: &mut Request) -> HeaderMap { headers } + +// ==== Renderer termination boundary ==== + +fn termination_reason( + status: TerminationStatus, +) -> tauri_runtime::webview::WebContentProcessTerminationReason { + use tauri_runtime::webview::WebContentProcessTerminationReason as Reason; + match status { + TerminationStatus::ABNORMAL_TERMINATION => Reason::Abnormal, + TerminationStatus::PROCESS_WAS_KILLED => Reason::Killed, + TerminationStatus::PROCESS_CRASHED => Reason::Crashed, + TerminationStatus::PROCESS_OOM => Reason::OutOfMemory, + TerminationStatus::LAUNCH_FAILED => Reason::LaunchFailed, + TerminationStatus::INTEGRITY_FAILURE => Reason::IntegrityFailure, + _ => Reason::Unknown, + } +} + +#[cfg(test)] +mod termination_tests { + use super::*; + use tauri_runtime::webview::WebContentProcessTerminationReason as Reason; + + #[test] + fn preserves_every_cef_termination_reason() { + for (status, expected) in [ + (TerminationStatus::ABNORMAL_TERMINATION, Reason::Abnormal), + (TerminationStatus::PROCESS_WAS_KILLED, Reason::Killed), + (TerminationStatus::PROCESS_CRASHED, Reason::Crashed), + (TerminationStatus::PROCESS_OOM, Reason::OutOfMemory), + (TerminationStatus::LAUNCH_FAILED, Reason::LaunchFailed), + ( + TerminationStatus::INTEGRITY_FAILURE, + Reason::IntegrityFailure, + ), + (TerminationStatus::NUM_VALUES, Reason::Unknown), + ] { + assert_eq!(termination_reason(status), expected); + } + } +} diff --git a/src/devtools.rs b/src/devtools.rs new file mode 100644 index 0000000..ca0ea77 --- /dev/null +++ b/src/devtools.rs @@ -0,0 +1,127 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::atomic::{AtomicI32, Ordering}; + +/// First identifier of the range reserved for the runtime's own requests. +/// +/// Callers allocate below this bound, starting at 1, and the runtime allocates +/// from it up to `i32::MAX`. Each counter is bounded by its own range and fails +/// closed there, so neither can ever reach the other one, no matter how many +/// identifiers are allocated. CDP identifiers are signed 32-bit integers, so +/// both ranges stay within what the DevTools agent accepts. +const RESERVED_RANGE_START: i32 = 1_000_000_000; + +static NEXT_MESSAGE_ID: AtomicI32 = AtomicI32::new(1); +static NEXT_RESERVED_MESSAGE_ID: AtomicI32 = AtomicI32::new(RESERVED_RANGE_START); + +/// The process-local native DevTools request identifier space is exhausted. +#[derive(Clone, Copy, Debug)] +pub struct DevToolsMessageIdExhausted; + +impl std::fmt::Display for DevToolsMessageIdExhausted { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("native CEF DevTools message identifiers are exhausted") + } +} + +impl std::error::Error for DevToolsMessageIdExhausted {} + +/// Allocates one native DevTools request ID for a caller of this crate. +/// +/// Observers see the whole browser, so every BrowserHost message a caller sends +/// must use this allocator to avoid consuming another caller's result. The +/// runtime allocates its own requests from a reserved range this function never +/// returns, so a caller cannot answer an internal request by picking its number. +/// +/// IDs are positive and never reused, including after cancellation or browser +/// teardown. Numeric correlation does not authorize a browser or document. +pub fn allocate_devtools_message_id() -> Result { + allocate_from(&NEXT_MESSAGE_ID, RESERVED_RANGE_START - 1) +} + +/// Allocates one native DevTools request ID for the runtime itself. +/// +/// The identifiers come from the range reserved above every caller-visible one, +/// so internal correlation (`pending_initial_loads` and the script evaluation +/// callbacks) only ever matches requests the runtime sent, even when a caller +/// ignores [`allocate_devtools_message_id`] and hardcodes an `id`. +pub(crate) fn allocate_runtime_devtools_message_id() -> Result { + allocate_from(&NEXT_RESERVED_MESSAGE_ID, i32::MAX) +} + +/// Allocates the next identifier of `counter`, which hands out values up to +/// `last`. Each range fails closed at its own boundary instead of wrapping or +/// growing into the other one. +fn allocate_from(counter: &AtomicI32, last: i32) -> Result { + counter + .try_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + if value > last { + return None; + } + value.checked_add(1) + }) + .map_err(|_| DevToolsMessageIdExhausted) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn concurrent_native_producers_never_share_a_response_id() { + let threads = (0..4) + .map(|_| { + std::thread::spawn(|| { + (0..1_000) + .map(|_| allocate_devtools_message_id().unwrap()) + .collect::>() + }) + }) + .collect::>(); + let ids = threads + .into_iter() + .flat_map(|thread| thread.join().unwrap()) + .collect::>(); + assert!(ids.iter().all(|id| *id > 0)); + assert_eq!(ids.iter().collect::>().len(), 4_000); + } + + #[test] + fn callers_and_the_runtime_never_share_a_response_id() { + let caller_ids = (0..1_000) + .map(|_| allocate_devtools_message_id().unwrap()) + .collect::>(); + let runtime_ids = (0..1_000) + .map(|_| allocate_runtime_devtools_message_id().unwrap()) + .collect::>(); + assert!( + caller_ids + .iter() + .all(|id| *id > 0 && *id < RESERVED_RANGE_START) + ); + assert!(runtime_ids.iter().all(|id| *id >= RESERVED_RANGE_START)); + assert!(caller_ids.is_disjoint(&runtime_ids)); + } + + #[test] + fn caller_exhaustion_cannot_reach_the_reserved_range() { + let last = RESERVED_RANGE_START - 1; + let counter = AtomicI32::new(last); + assert_eq!(allocate_from(&counter, last).unwrap(), last); + assert!(allocate_from(&counter, last).is_err()); + assert!(allocate_from(&counter, last).is_err()); + assert_eq!(counter.load(Ordering::Relaxed), RESERVED_RANGE_START); + } + + #[test] + fn exhaustion_cannot_reuse_a_late_response_id() { + let counter = AtomicI32::new(i32::MAX - 1); + assert_eq!(allocate_from(&counter, i32::MAX).unwrap(), i32::MAX - 1); + assert!(allocate_from(&counter, i32::MAX).is_err()); + assert!(allocate_from(&counter, i32::MAX).is_err()); + assert_eq!(counter.load(Ordering::Relaxed), i32::MAX); + } +} diff --git a/src/dialog.rs b/src/dialog.rs new file mode 100644 index 0000000..7b0320d --- /dev/null +++ b/src/dialog.rs @@ -0,0 +1,207 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Native protocol observations of CEF's existing JavaScript dialog UI. +//! No custom dialog handler, callback, message text, or prompt value is retained. +//! +//! These events reach the runtime through the DevTools `Page` domain it enables +//! on every browser. Enabling that domain observes dialogs without taking them +//! over: Chromium notifies each enabled `PageHandler` and *then* still runs the +//! browser's own dialog manager, and CEF always supplies one for its browsers. +//! A dialog only stalls a page when no browser handler exists at all, which the +//! protocol itself reports as `hasBrowserHandler == false`. The runtime +//! therefore never has to answer a dialog with `Page.handleJavaScriptDialog`. + +use crate::{FrameNavigationState, NativeDocumentToken}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum NativeDialogKind { + Alert, + Confirm, + Prompt, + BeforeUnload, +} + +/// Opaque lifetime of one native JavaScript dialog notification. Compare with +/// the current UI-thread snapshot before submitting any dialog action. +#[derive(Clone)] +pub struct NativeDialogToken(Arc<()>); +impl PartialEq for NativeDialogToken { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} +impl Eq for NativeDialogToken {} +impl std::fmt::Debug for NativeDialogToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NativeDialogToken").finish_non_exhaustive() + } +} + +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct NativeDialogSnapshot { + pub token: NativeDialogToken, + pub kind: NativeDialogKind, + /// Chromium reports a non-DevTools dialog handler. This does not establish + /// native visibility, the exact button label, or the current prompt value. + pub has_browser_handler: bool, +} + +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct NativeDialogObservation { + /// A dialog event was observed for this document. False never means absent. + pub known: bool, + pub dialog: Option, +} + +#[derive(Default)] +struct ObservedDialog { + document: Option, + observation: NativeDialogObservation, +} + +#[derive(Clone)] +pub(crate) struct DialogState { + browser: FrameNavigationState, + state: Arc>, +} +impl DialogState { + pub(crate) fn new(browser: FrameNavigationState) -> Self { + Self { + browser, + state: Arc::default(), + } + } + pub(crate) fn accepts_browser(&self, id: i32) -> bool { + self.browser.has_browser_id(id) + } + pub(crate) fn snapshot(&self, document: Option<&NativeDocumentToken>) -> NativeDialogObservation { + self + .state + .lock() + .ok() + .filter(|state| state.document.as_ref() == document) + .map(|state| state.observation.clone()) + .unwrap_or_default() + } + pub(crate) fn on_event(&self, method: &str, params: &[u8]) { + let observation = match method { + "Page.javascriptDialogOpening" => { + #[derive(serde::Deserialize)] + struct Opening { + #[serde(rename = "type")] + kind: NativeDialogKind, + #[serde(rename = "hasBrowserHandler")] + has_browser_handler: bool, + } + // Dialog payloads contain arbitrary page text. Bound parsing and retain + // only the native kind/handler facts; malformed input revokes old refs. + let opening = (params.len() <= 262_144) + .then(|| serde_json::from_slice::(params).ok()) + .flatten(); + opening + .map(|opening| NativeDialogObservation { + known: true, + dialog: Some(NativeDialogSnapshot { + token: NativeDialogToken(Arc::new(())), + kind: opening.kind, + has_browser_handler: opening.has_browser_handler, + }), + }) + .unwrap_or_default() + } + "Page.javascriptDialogClosed" => NativeDialogObservation { + known: true, + dialog: None, + }, + _ => return, + }; + let document = self.browser.document(); + if let Ok(mut state) = self.state.lock() { + *state = ObservedDialog { + document, + observation, + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn ready() -> FrameNavigationState { + let browser = FrameNavigationState::new(); + for kind in [ + crate::FrameEventKind::Created, + crate::FrameEventKind::Attached, + crate::FrameEventKind::MainFrameChanged, + crate::FrameEventKind::LoadingStateChanged { is_loading: false }, + ] { + browser.on_frame_event(&crate::FrameEvent { + browser_id: 1, + frame_id: "main".into(), + is_main: true, + kind, + }); + } + browser + } + const CONFIRM: &[u8] = br#"{"type":"confirm","hasBrowserHandler":true,"message":"non-secret fixture","defaultPrompt":"omitted fixture"}"#; + #[test] + fn exact_dialog_lifetimes_rotate_on_reopen_and_do_not_cross_browsers() { + let browser = ready(); + let state = DialogState::new(browser.clone()); + let document = browser.document().unwrap(); + assert!(!state.snapshot(Some(&document)).known); + state.on_event("Page.javascriptDialogOpening", CONFIRM); + let first = state.snapshot(Some(&document)).dialog.unwrap(); + assert_eq!(first.kind, NativeDialogKind::Confirm); + assert!(first.has_browser_handler); + state.on_event("Page.javascriptDialogClosed", b"unread prompt value"); + assert!(state.snapshot(Some(&document)).known); + assert!(state.snapshot(Some(&document)).dialog.is_none()); + state.on_event("Page.javascriptDialogOpening", CONFIRM); + assert_ne!( + state.snapshot(Some(&document)).dialog.unwrap().token, + first.token + ); + let other = DialogState::new(ready()); + other.on_event("Page.javascriptDialogOpening", CONFIRM); + assert_ne!( + other + .state + .lock() + .unwrap() + .observation + .dialog + .as_ref() + .unwrap() + .token, + first.token + ); + assert!(!state.accepts_browser(2)); + } + #[test] + fn navigation_unknown_protocol_and_missing_handler_facts_fail_closed() { + let browser = ready(); + let state = DialogState::new(browser.clone()); + let document = browser.document().unwrap(); + state.on_event("Page.javascriptDialogOpening", CONFIRM); + browser.close(); + assert!(!state.snapshot(browser.document().as_ref()).known); + state.on_event("Page.javascriptDialogOpening", br#"{"type":"confirm"}"#); + assert!(!state.snapshot(None).known); + state.on_event( + "Page.javascriptDialogOpening", + br#"{"type":"unknown","hasBrowserHandler":true}"#, + ); + assert!(state.snapshot(None).dialog.is_none()); + assert!(state.snapshot(Some(&document)).dialog.is_none()); + } +} diff --git a/src/external_message_pump/linux.rs b/src/external_message_pump/linux.rs index 8a3fefb..96aa8fe 100644 --- a/src/external_message_pump/linux.rs +++ b/src/external_message_pump/linux.rs @@ -136,6 +136,10 @@ impl PlatformPump { pub(super) fn is_timer_pending(&self) -> bool { get_time_interval_milliseconds(self.source_state.delayed_work_time) > 0 } + + pub(super) fn deadline(&self) -> Option { + self.source_state.delayed_work_time + } } impl Drop for PlatformPump { @@ -219,15 +223,15 @@ unsafe fn handle_check(source_state: *mut SourceState) -> bool { if num_bytes < mem::size_of::() as isize { log::error!("error reading from the CEF message pump wakeup pipe"); } - if num_bytes == mem::size_of::() as isize { - if let Some(state) = unsafe { (*source_state).state.upgrade() } { - state.on_schedule_work(delay_ms[0]); - } + if num_bytes == mem::size_of::() as isize + && let Some(state) = unsafe { (*source_state).state.upgrade() } + { + state.on_schedule_work(delay_ms[0]); } - if num_bytes == (mem::size_of::() * 2) as isize { - if let Some(state) = unsafe { (*source_state).state.upgrade() } { - state.on_schedule_work(delay_ms[1]); - } + if num_bytes == (mem::size_of::() * 2) as isize + && let Some(state) = unsafe { (*source_state).state.upgrade() } + { + state.on_schedule_work(delay_ms[1]); } } diff --git a/src/external_message_pump/mod.rs b/src/external_message_pump/mod.rs index 51fe332..0165ac3 100644 --- a/src/external_message_pump/mod.rs +++ b/src/external_message_pump/mod.rs @@ -95,6 +95,19 @@ impl CefExternalPump { pub(crate) fn do_work(&self) { self.state.do_work(); } + + /// When the platform timer is next due. This is only needed by event loops + /// that do not block in the GLib main context themselves. + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + pub(crate) fn next_deadline(&self) -> Option { + self.state.platform.lock().ok().and_then(|p| p.deadline()) + } } /// Platform-independent pump state, shared with the [`PlatformPump`] backend. diff --git a/src/frame.rs b/src/frame.rs new file mode 100644 index 0000000..0af9579 --- /dev/null +++ b/src/frame.rs @@ -0,0 +1,79 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::Arc; + +/// One browser-process notification about a native CEF frame. +/// +/// Notifications run synchronously on CEF's UI thread. Handlers must return +/// promptly and must not call APIs that wait for the event loop. Unlike Tauri's +/// portable navigation callbacks, these notifications include child frames. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct FrameEvent { + /// Native browser identity. Every event delivered to one webview's observer + /// carries that webview's own identity; a CEF-owned popup is a different + /// browser and is not reported here. + pub browser_id: i32, + /// CEF's opaque identifier for this native frame lifetime. + /// Empty for a browser-wide notification when no main frame exists. + pub frame_id: String, + /// Whether CEF identifies this as the main frame at callback time. + pub is_main: bool, + /// Native lifecycle phase. + pub kind: FrameEventKind, +} + +/// Native lifecycle phases reported for every frame. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum FrameEventKind { + /// A frame object exists, but may not yet have a renderer connection. + Created, + /// Commands can be routed to the renderer. Reattachment is also reported. + Attached, + /// The frame can no longer route commands to its renderer. + Detached, + /// The native frame object is being destroyed. + Destroyed, + /// A navigation was admitted by the existing navigation policy, before commit. + NavigationStarted { url: url::Url }, + /// A document committed, before its contents begin loading. + DocumentCommitted { url: url::Url }, + /// Navigation failed or was cancelled. This is not a document-ready signal. + NavigationFailed { url: url::Url }, + /// An address changed, including a same-document history or fragment change. + AddressChanged { url: url::Url }, + /// The browser assigned this frame as its main frame. + MainFrameChanged, + /// Browser-wide load state. `false` follows every frame's load-end/error + /// notifications, including cancelled navigation. It does not assert DOM, + /// application, network-idle, or renderer responsiveness. + LoadingStateChanged { is_loading: bool }, + /// The renderer terminated. Prior document generations cannot be reused. + RendererTerminated, +} + +/// Synchronous observer for native frame lifecycle events. +pub type FrameEventHandler = dyn Fn(FrameEvent) + Send + Sync + 'static; + +pub(crate) fn emit_frame_event( + handler: &Option>, + browser: Option<&mut cef::Browser>, + frame: Option<&mut cef::Frame>, + kind: FrameEventKind, +) { + use cef::{ImplBrowser, ImplFrame}; + if let (Some(handler), Some(browser)) = (handler, browser) { + handler(FrameEvent { + browser_id: browser.identifier(), + frame_id: frame + .as_ref() + .map(|frame| cef::CefString::from(&frame.identifier()).to_string()) + .unwrap_or_default(), + is_main: frame.is_some_and(|frame| frame.is_main() != 0), + kind, + }); + } +} diff --git a/src/frame_navigation.rs b/src/frame_navigation.rs new file mode 100644 index 0000000..5955056 --- /dev/null +++ b/src/frame_navigation.rs @@ -0,0 +1,450 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Native document generations shared by every CEF webview. + +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +use std::sync::{Arc, Mutex}; + +const MAX_NATIVE_FRAMES: usize = 256; +const MAX_NATIVE_FRAME_ID_BYTES: usize = 512; + +#[derive(Debug, Default)] +struct NativeFrameState { + browser_id: Option, + generation: u64, + exhausted: bool, + loading: bool, + observed_load_state: bool, + main_frame: Option, + frames: BTreeMap, +} + +/// Read-only navigation state for one exact native CEF browser lifetime. The CEF UI thread +/// advances it before input submission can be admitted on that same thread. +/// No lock is held while calling CEF or awaiting a renderer response. +#[derive(Clone, Debug)] +pub struct FrameNavigationState { + state: Arc>, +} + +/// Opaque process-local proof of an observed native browser/document lifetime. +/// Compare it with `WebviewSnapshot::document` in the final UI-thread callback +/// before an effect. A token alone does not authorize an account or profile. +#[derive(Clone)] +pub struct NativeDocumentToken { + state: Arc>, + generation: u64, +} + +impl PartialEq for NativeDocumentToken { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.state, &other.state) && self.generation == other.generation + } +} +impl Eq for NativeDocumentToken {} + +impl std::fmt::Debug for NativeDocumentToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NativeDocumentToken") + .finish_non_exhaustive() + } +} + +impl FrameNavigationState { + pub(crate) fn new() -> Self { + Self { + state: Arc::default(), + } + } + + /// Identifies the same native browser lifetime independently of navigation. + /// A replacement browser never matches, even if a caller reuses its label. + pub fn is_same_browser(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.state, &other.state) + } + + pub(crate) fn has_browser_id(&self, browser_id: i32) -> bool { + self + .state + .lock() + .is_ok_and(|state| state.browser_id == Some(browser_id)) + } + + pub(crate) fn close(&self) { + if let Ok(mut state) = self.state.lock() { + state.exhausted = true; + } + } + + pub(crate) fn observe_document(&self, browser: &cef::Browser) -> Option { + use cef::ImplBrowser; + let generation = self.ready_generation()?; + if browser.is_valid() == 0 || browser.frame_count() > MAX_NATIVE_FRAMES { + return None; + } + let mut identifiers = cef::CefStringList::new(); + browser.frame_identifiers(Some(&mut identifiers)); + self + .admits_native_frames( + generation, + browser.identifier(), + browser.is_loading() != 0, + &identifiers.into_iter().collect(), + ) + .then(|| NativeDocumentToken { + state: Arc::clone(&self.state), + generation, + }) + } + + /// Captures an observed document only when every known frame is attached and + /// native load completion has been observed. Compare with the final native + /// snapshot before dispatch; this read does not query current CEF frame IDs. + pub fn document(&self) -> Option { + self + .ready_generation() + .map(|generation| NativeDocumentToken { + state: Arc::clone(&self.state), + generation, + }) + } + + fn ready_generation(&self) -> Option { + let state = self.state.lock().ok()?; + let ready = Self::ready(&state); + if !ready { + log::debug!( + "native document unavailable: exhausted={}, load_observed={}, loading={}, frames={}, attached={}, main_attached={}", + state.exhausted, + state.observed_load_state, + state.loading, + state.frames.len(), + state.frames.values().filter(|attached| **attached).count(), + state + .main_frame + .as_ref() + .is_some_and(|id| state.frames.get(id) == Some(&true)), + ); + } + ready.then_some(state.generation) + } + + fn ready(state: &NativeFrameState) -> bool { + !state.exhausted + && state.observed_load_state + && !state.loading + && !state.frames.is_empty() + // Exceeding the tracked frame cap fails admission closed for as long as + // it lasts, matching `observe_document`'s native `frame_count` guard. + // Frames keep being tracked exactly, so draining below the cap recovers. + && state.frames.len() <= MAX_NATIVE_FRAMES + && state.frames.values().all(|attached| *attached) + && state + .main_frame + .as_ref() + .is_some_and(|id| state.frames.get(id) == Some(&true)) + } + + pub(crate) fn on_frame_event(&self, event: &crate::FrameEvent) { + self.apply( + event.browser_id, + &event.frame_id, + event.is_main, + &event.kind, + ); + } + + fn apply(&self, browser_id: i32, frame_id: &str, is_main: bool, kind: &crate::FrameEventKind) { + use crate::FrameEventKind; + + let Ok(mut state) = self.state.lock() else { + return; + }; + if state.exhausted { + return; + } + // Runtime-managed popups can inherit an opener's event handlers. Their + // browser identity never advances or grants authority over the opener. + if state + .browser_id + .is_some_and(|expected| expected != browser_id) + { + return; + } + // Bind the native browser identity before any rejection below can latch + // exhaustion. IPC admission and event delivery are gated on this identity, + // so an unexpected first notification must never leave it unrecorded. + state.browser_id = Some(browser_id); + let browser_wide = matches!( + kind, + FrameEventKind::MainFrameChanged + | FrameEventKind::LoadingStateChanged { .. } + | FrameEventKind::RendererTerminated + ); + if frame_id.is_empty() && !browser_wide || frame_id.len() > MAX_NATIVE_FRAME_ID_BYTES { + log::warn!( + "native frame tracking exhausted: invalid identifier, empty={}, length={}, main={}, teardown={}", + frame_id.is_empty(), + frame_id.len(), + is_main, + matches!(kind, FrameEventKind::Detached | FrameEventKind::Destroyed), + ); + state.exhausted = true; + return; + } + let Some(generation) = state.generation.checked_add(1) else { + state.exhausted = true; + return; + }; + state.generation = generation; + match kind { + FrameEventKind::Created => { + state.frames.insert(frame_id.to_string(), false); + } + FrameEventKind::Attached => { + state.frames.insert(frame_id.to_string(), true); + } + FrameEventKind::Detached | FrameEventKind::Destroyed => { + state.frames.remove(frame_id); + if state.main_frame.as_deref() == Some(frame_id) { + state.main_frame = None; + } + } + FrameEventKind::MainFrameChanged => { + state.main_frame = is_main.then(|| frame_id.to_string()); + } + FrameEventKind::NavigationStarted { .. } => { + state.loading = true; + } + FrameEventKind::LoadingStateChanged { is_loading } => { + state.observed_load_state = true; + state.loading = *is_loading; + } + FrameEventKind::DocumentCommitted { .. } + | FrameEventKind::NavigationFailed { .. } + | FrameEventKind::AddressChanged { .. } => {} + FrameEventKind::RendererTerminated => { + state.frames.clear(); + state.main_frame = None; + state.loading = true; + state.observed_load_state = false; + } + } + } + + /// Final native admission compares the exact current CEF frame identities, + /// not just their count. A replacement cannot reuse a document capability. + pub(crate) fn admits_native_frames( + &self, + expected: u64, + browser_id: i32, + is_loading: bool, + frame_ids: &BTreeSet, + ) -> bool { + let Ok(state) = self.state.lock() else { + return false; + }; + Self::ready(&state) + && state.generation == expected + && state.browser_id == Some(browser_id) + && !is_loading + && state.frames.keys().eq(frame_ids.iter()) + } +} + +#[cfg(test)] +mod tests { + use crate::FrameEventKind as Event; + + use super::*; + + fn apply(barrier: &FrameNavigationState, frame: &str, kind: Event) { + barrier.apply(1, frame, frame == "main", &kind); + } + + fn ready_main() -> FrameNavigationState { + let barrier = FrameNavigationState::new(); + apply(&barrier, "main", Event::Created); + apply(&barrier, "main", Event::MainFrameChanged); + apply(&barrier, "main", Event::Attached); + apply( + &barrier, + "main", + Event::LoadingStateChanged { is_loading: false }, + ); + barrier + } + + fn admits(barrier: &FrameNavigationState, generation: u64, frames: &[&str]) -> bool { + barrier.admits_native_frames( + generation, + 1, + false, + &frames.iter().map(|id| id.to_string()).collect(), + ) + } + + #[test] + fn child_navigation_replacement_and_detach_revoke_prior_documents() { + let barrier = ready_main(); + let main_generation = barrier.ready_generation().unwrap(); + assert!(admits(&barrier, main_generation, &["main"])); + apply(&barrier, "child-a", Event::Created); + assert!(barrier.ready_generation().is_none()); + apply(&barrier, "child-a", Event::Attached); + let child_generation = barrier.ready_generation().unwrap(); + assert!(!admits(&barrier, main_generation, &["main", "child-a"])); + assert!(admits(&barrier, child_generation, &["main", "child-a"])); + let url = url::Url::parse("https://example.test/frame").unwrap(); + apply( + &barrier, + "child-a", + Event::NavigationStarted { url: url.clone() }, + ); + assert!(barrier.ready_generation().is_none()); + apply(&barrier, "child-a", Event::DocumentCommitted { url }); + assert!(barrier.ready_generation().is_none()); + apply( + &barrier, + "main", + Event::LoadingStateChanged { is_loading: false }, + ); + assert!(!admits(&barrier, child_generation, &["main", "child-a"])); + let loaded_generation = barrier.ready_generation().unwrap(); + assert!(admits(&barrier, loaded_generation, &["main", "child-a"])); + // Even an equal native frame count must reject different identities. + assert!(!admits(&barrier, loaded_generation, &["main", "child-b"])); + apply(&barrier, "child-a", Event::Detached); + apply(&barrier, "child-b", Event::Created); + apply(&barrier, "child-b", Event::Attached); + assert!(!admits(&barrier, loaded_generation, &["main", "child-b"])); + let replacement_generation = barrier.ready_generation().unwrap(); + assert!(admits( + &barrier, + replacement_generation, + &["main", "child-b"] + )); + apply(&barrier, "child-b", Event::Detached); + assert!(!admits(&barrier, replacement_generation, &["main"])); + assert!(admits( + &barrier, + barrier.ready_generation().unwrap(), + &["main"] + )); + } + + #[test] + fn pending_cancelled_navigation_waits_for_native_loading_completion() { + let barrier = ready_main(); + let url = url::Url::parse("https://example.test/repeated").unwrap(); + apply( + &barrier, + "main", + Event::NavigationStarted { url: url.clone() }, + ); + apply( + &barrier, + "main", + Event::NavigationStarted { url: url.clone() }, + ); + apply(&barrier, "main", Event::NavigationFailed { url }); + assert!(barrier.ready_generation().is_none()); + apply( + &barrier, + "main", + Event::LoadingStateChanged { is_loading: false }, + ); + assert!(barrier.ready_generation().is_some()); + } + + #[test] + fn foreign_browser_unobserved_frames_and_exhaustion_fail_closed() { + let barrier = ready_main(); + let generation = barrier.ready_generation().unwrap(); + barrier.apply(2, "popup", true, &Event::Created); + assert_eq!(barrier.ready_generation(), Some(generation)); + assert!(!barrier.admits_native_frames( + generation, + 2, + false, + &BTreeSet::from(["main".to_string()]) + )); + assert!(!admits(&barrier, generation, &["main", "unobserved"])); + barrier.state.lock().unwrap().generation = u64::MAX; + apply(&barrier, "main", Event::Attached); + assert!(barrier.ready_generation().is_none()); + apply( + &barrier, + "main", + Event::LoadingStateChanged { is_loading: false }, + ); + assert!(barrier.ready_generation().is_none()); + } + #[test] + fn missing_main_frame_and_renderer_termination_revoke_prior_generations() { + let barrier = ready_main(); + let generation = barrier.ready_generation().unwrap(); + barrier.apply(1, "", false, &Event::MainFrameChanged); + assert!(barrier.ready_generation().is_none()); + apply(&barrier, "main", Event::MainFrameChanged); + assert_ne!(barrier.ready_generation().unwrap(), generation); + barrier.apply(1, "", false, &Event::RendererTerminated); + assert!(barrier.ready_generation().is_none()); + barrier.apply( + 1, + "", + false, + &Event::LoadingStateChanged { is_loading: false }, + ); + assert!(barrier.ready_generation().is_none()); + apply(&barrier, "replacement", Event::Created); + apply(&barrier, "replacement", Event::Attached); + barrier.apply(1, "replacement", true, &Event::MainFrameChanged); + assert_ne!(barrier.ready_generation().unwrap(), generation); + } + #[test] + fn document_tokens_cannot_cross_native_lifetimes_or_navigation() { + let first = ready_main(); + let second = ready_main(); + assert_eq!(first.ready_generation(), second.ready_generation()); + assert!(first.is_same_browser(&first.clone())); + assert!(!first.is_same_browser(&second)); + let before = first.document().unwrap(); + assert_eq!(before, first.clone().document().unwrap()); + assert_ne!(before, second.document().unwrap()); + apply(&first, "child", Event::Created); + assert!(first.document().is_none()); + apply(&first, "child", Event::Attached); + assert_ne!(before, first.document().unwrap()); + } + #[test] + fn a_rejected_first_event_still_records_the_native_browser_identity() { + // `AddressChanged`/`NavigationFailed` tolerate a missing native frame, so a + // browser's very first notification can carry an empty identifier. + let url = url::Url::parse("https://example.test/frameless").unwrap(); + let barrier = FrameNavigationState::new(); + barrier.apply(1, "", false, &Event::AddressChanged { url }); + // Document admission still fails closed, ... + assert!(barrier.document().is_none()); + // ... but IPC and event delivery stay bound to this exact browser. + assert!(barrier.has_browser_id(1)); + assert!(!barrier.has_browser_id(2)); + } + #[test] + fn a_transient_frame_count_overflow_recovers_once_frames_drain() { + let barrier = ready_main(); + for index in 0..MAX_NATIVE_FRAMES { + apply(&barrier, &format!("child-{index}"), Event::Created); + apply(&barrier, &format!("child-{index}"), Event::Attached); + } + assert!(barrier.ready_generation().is_none()); + apply(&barrier, "child-0", Event::Detached); + assert!(barrier.ready_generation().is_some()); + assert!(barrier.has_browser_id(1)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2619a31..c7174a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,38 +6,34 @@ #![allow(clippy::too_many_arguments)] mod cef_impl; -mod compat; -mod config; +mod devtools; +mod dialog; mod external_message_pump; +mod frame; +pub use dialog::{ + NativeDialogKind, NativeDialogObservation, NativeDialogSnapshot, NativeDialogToken, +}; +mod frame_navigation; +mod macros; mod platform; -mod policy; +mod popup; mod runtime; -mod streaming; -#[cfg(target_os = "linux")] -mod wayland; +mod tauri_ext; mod webview; +pub use devtools::{DevToolsMessageIdExhausted, allocate_devtools_message_id}; +pub use frame::{FrameEvent, FrameEventHandler, FrameEventKind}; +pub use frame_navigation::{FrameNavigationState, NativeDocumentToken}; mod window; mod window_builder; mod window_handle; -pub use config::{CefConfig, LinuxWindowing, configure}; -#[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" -))] -pub use platform::linux::install_x_error_handlers; -pub use policy::{ - DEFAULT_PROMPT_TIMEOUT, DeferredResponder, DenyReason, NormalizedOrigin, PermissionAudit, - PermissionKind, PermissionRequest, PermissionResponder, PopupRequest, RequestSource, Verdict, - set_permission_audit, set_permission_policy, set_popup_policy, -}; +pub use cef::sys::CEF_API_VERSION_LAST; +#[cfg(target_os = "macos")] +pub use platform::macos::setup_application as prepare_macos_application; pub use runtime::*; -pub use streaming::{ - InitiatorOrigin, StreamClosed, StreamResponder, StreamWriter, register_streaming_scheme_handler, -}; +pub use tauri_ext::*; +/// Marks the application entry point so non-browser CEF processes (renderer, GPU, ...) are handled. +pub use tauri_macros::cef_entry_point; pub use webview::*; -pub use window::CefWindowDispatcher; +pub use window::{CefWindowDispatcher, NativeWindowToken}; pub use window_builder::WindowBuilderWrapper; diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..765b3ce --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,84 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/// Forwards a declaration to one of `cef`'s `wrap_*!` macros and adds a +/// named-args constructor next to the positional `new()` they generate. +/// +/// `new()` takes one argument per struct field, so the wrappers that carry a +/// lot of state — `TauriCefBrowserClient` most of all — are built from a long +/// positional list in which two same-typed neighbours (`devtools_enabled` and +/// `drag_drop_handler_enabled`, say) can be swapped without the compiler +/// noticing. `build()` takes a single args struct instead, so every value is +/// named at the call site, and the forwarding to `new()` is generated from the +/// very field list the wrapper was declared with. +/// +/// ```ignore +/// wrap_with_args! { +/// wrap_client => TauriCefBrowserClientArgs; +/// +/// pub(crate) struct TauriCefBrowserClient { +/// pub(crate) context: RuntimeContext, +/// devtools_enabled: bool, +/// } +/// +/// impl Client { +/// // ... same method bodies as a plain `wrap_client!` block +/// } +/// } +/// +/// TauriCefBrowserClient::build(TauriCefBrowserClientArgs { +/// context, +/// devtools_enabled: true, +/// }); +/// ``` +/// +/// The args fields are `pub(crate)` whatever visibility the wrapper gives its +/// own fields: anything that can name the args struct has to be able to fill in +/// every one of them, while the wrapper keeps its own fields as private as it +/// declared them. +macro_rules! wrap_with_args { + ( + $wrap:ident => $args:ident; + + $vis:vis struct $name:ident$(< + $($generic_type:ident : $first_generic_type_bound:tt $(+ $generic_type_bound:tt)*),+ $(,)? + >)? { + $($field_vis:vis $field_name:ident: $field_type:ty),* $(,)? + } + + impl $interface:ident { + $($methods:tt)* + } + ) => { + $vis struct $args$(<$($generic_type,)+>)? + $(where + $($generic_type: $first_generic_type_bound $(+ $generic_type_bound)*,)+ + )? + { + $(pub(crate) $field_name: $field_type,)* + } + + $wrap! { + $vis struct $name$(<$($generic_type: $first_generic_type_bound $(+ $generic_type_bound)*),+>)? { + $($field_vis $field_name: $field_type,)* + } + + impl $interface { + $($methods)* + } + } + + impl$(<$($generic_type,)+>)? $name$(<$($generic_type,)+>)? + $(where + $($generic_type: $first_generic_type_bound $(+ $generic_type_bound)*,)+ + )? + { + $vis fn build(args: $args$(<$($generic_type,)+>)?) -> $interface { + Self::new($(args.$field_name),*) + } + } + }; +} + +pub(crate) use wrap_with_args; diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index 3ce875b..f49c04e 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -8,5 +8,3 @@ mod taskbar; mod utils; mod webview; mod window; - -pub use utils::install_x_error_handlers; diff --git a/src/platform/linux/utils.rs b/src/platform/linux/utils.rs index a991953..36e04d6 100644 --- a/src/platform/linux/utils.rs +++ b/src/platform/linux/utils.rs @@ -5,13 +5,15 @@ use std::{ cell::RefCell, ffi::CString, - os::raw::{c_int, c_long, c_ulong}, + os::raw::{c_long, c_ulong}, sync::LazyLock, }; use x11_dl::xlib; const NET_WM_STATE_REMOVE: c_long = 0; const NET_WM_STATE_ADD: c_long = 1; +const NET_ACTIVE_WINDOW_SOURCE_PAGER: c_long = 2; +const CURRENT_TIME: c_long = 0; const CLIENT_MESSAGE: i32 = 33; const SUBSTRUCTURE_REDIRECT_MASK: c_long = 1 << 20; const SUBSTRUCTURE_NOTIFY_MASK: c_long = 1 << 19; @@ -67,65 +69,53 @@ pub(super) fn with_x11(default: R, f: impl FnOnce(&xlib::Xlib, *mut xlib::Dis }) } -unsafe extern "C" fn x_error_handler( - _display: *mut xlib::Display, - event: *mut xlib::XErrorEvent, -) -> c_int { - if !event.is_null() { - let event = unsafe { &*event }; - log::warn!( - "X error received: type {}, serial {}, error_code {}, request_code {}, minor_code {}", - event.type_, - event.serial, - event.error_code, - event.request_code, - event.minor_code - ); - } - 0 -} - -unsafe extern "C" fn x_io_error_handler(_display: *mut xlib::Display) -> c_int { - log::error!("X IO error received: the display connection is gone"); - 0 +pub(super) fn atom(xlib: &xlib::Xlib, display: *mut xlib::Display, name: &str) -> c_ulong { + let cname = CString::new(name).unwrap(); + unsafe { (xlib.XInternAtom)(display, cname.as_ptr(), 0) } } -/// Replace Xlib's process-killing default error handlers with logging no-ops. -/// -/// Xlib terminates the process on error by default: the stock error handler -/// prints and calls `exit(1)`, and the IO-error handler exits when the display -/// connection breaks. A non-fatal X protocol error — the kind a compositor or a -/// GPU reset produces on display resume — therefore takes the whole app down -/// with no Rust panic and no backtrace. -/// -/// Mirrors cefclient's `XErrorHandlerImpl`/`XIOErrorHandlerImpl`, installed -/// there for the same reason: -/// +/// Ask the window manager to make `xid` the active window. /// -/// The runtime installs these itself after `cef::initialize`. **An embedder -/// that calls `gtk_init` must call this again afterwards**: GTK's X11 backend -/// installs its own handler during init, replacing whatever was there. This is -/// why cefclient installs its handlers *after* `gtk_init` rather than before. -/// Calling this more than once is harmless. -pub fn install_x_error_handlers() { - #[cfg(target_os = "linux")] - if crate::config::native_wayland() { - return; - } - - let Some(xlib) = XLIB.as_ref() else { - return; - }; +/// The source indication is `2` ("pager"): EWMH tells window managers to treat +/// those requests as if they came from the user, which is what gets past the +/// focus-stealing prevention that would otherwise turn the request into a +/// taskbar highlight. `1` ("application") would need a valid user input +/// timestamp we do not have when a window is created. +pub(super) fn activate_window(xid: c_ulong) { + with_x11((), |xlib, display| { + let net_active_window = atom(xlib, display, "_NET_ACTIVE_WINDOW"); - unsafe { - (xlib.XSetErrorHandler)(Some(x_error_handler)); - (xlib.XSetIOErrorHandler)(Some(x_io_error_handler)); - } -} + unsafe { + (xlib.XRaiseWindow)(display, xid); -pub(super) fn atom(xlib: &xlib::Xlib, display: *mut xlib::Display, name: &str) -> c_ulong { - let cname = CString::new(name).unwrap(); - unsafe { (xlib.XInternAtom)(display, cname.as_ptr(), 0) } + let root = (xlib.XDefaultRootWindow)(display); + let mut event: xlib::XEvent = std::mem::zeroed(); + event.client_message = xlib::XClientMessageEvent { + type_: CLIENT_MESSAGE, + serial: 0, + send_event: 1, + display, + window: xid, + message_type: net_active_window, + format: 32, + data: xlib::ClientMessageData::from([ + NET_ACTIVE_WINDOW_SOURCE_PAGER, + CURRENT_TIME, + // No requestor window: the request is about our own window. + 0, + 0, + 0, + ]), + }; + (xlib.XSendEvent)( + display, + root, + 0, + SUBSTRUCTURE_REDIRECT_MASK | SUBSTRUCTURE_NOTIFY_MASK, + &mut event, + ); + } + }); } pub(super) fn set_wm_state(xid: c_ulong, add: bool, atom1: &str, atom2: Option<&str>) { diff --git a/src/platform/linux/webview.rs b/src/platform/linux/webview.rs index f63f6de..546b160 100644 --- a/src/platform/linux/webview.rs +++ b/src/platform/linux/webview.rs @@ -13,6 +13,46 @@ use crate::{webview::AppWebview, window::AppWindow}; use super::utils::{atom, with_cef_display}; impl AppWebview { + pub(crate) fn native_parent_matches(&self, parent: &AppWindow) -> Option { + let xid = self.host.window_handle(); + if xid == 0 { + return None; + } + with_cef_display(None, |xlib, display| unsafe { + let mut root = 0; + let mut native_parent = 0; + let mut children = std::ptr::null_mut(); + let mut count = 0; + let status = (xlib.XQueryTree)( + display, + xid as xlib::Window, + &mut root, + &mut native_parent, + &mut children, + &mut count, + ); + if !children.is_null() { + (xlib.XFree)(children.cast()); + } + (status != 0).then_some(native_parent == parent.xid() as xlib::Window) + }) + } + + pub(crate) fn native_visible(&self) -> Option { + let xid = self.host.window_handle(); + if xid == 0 { + return None; + } + with_cef_display(None, |xlib, display| unsafe { + let mut attributes = std::mem::MaybeUninit::::uninit(); + if (xlib.XGetWindowAttributes)(display, xid as xlib::Window, attributes.as_mut_ptr()) == 0 { + return None; + } + // XGetWindowAttributes initializes the complete structure on success. + Some(attributes.assume_init().map_state == xlib::IsViewable) + }) + } + fn xid(&self) -> xlib::Window { let xid = self.host.window_handle(); assert_ne!(xid, 0, "failed to get XID"); @@ -59,28 +99,6 @@ impl AppWebview { }) } - /// Give the X11 keyboard focus to the browser's own window. - /// - /// Without it keys only arrive while the pointer is over the window, because - /// X11 routes them through the pointer window and Chromium treats a window - /// with neither focus nor pointer as inactive. Alloy does this in - /// `CefWindowX11::Focus`; Chrome-style child windows have no equivalent. - pub(crate) fn take_input_focus(&self) { - let xid = self.xid(); - - with_cef_display((), |xlib, display| unsafe { - // Focusing an unmapped window is a BadMatch; a hidden webview is unmapped. - let mut attributes: xlib::XWindowAttributes = std::mem::zeroed(); - if (xlib.XGetWindowAttributes)(display, xid, &mut attributes) == 0 - || attributes.map_state != xlib::IsViewable - { - return; - } - - (xlib.XSetInputFocus)(display, xid, xlib::RevertToParent, xlib::CurrentTime); - }); - } - pub(crate) fn reparent(&self, parent: &AppWindow) { let xid = self.xid(); let parent_xid = parent.xid(); @@ -127,14 +145,6 @@ impl AppWebview { }); } - pub(crate) fn destroy_native(&self) { - let xid = self.xid(); - with_cef_display((), |xlib, display| unsafe { - (xlib.XDestroyWindow)(display, xid); - (xlib.XFlush)(display); - }); - } - pub(crate) fn apply_physical_bounds(&self, _scale: f64, x: i32, y: i32, width: i32, height: i32) { let xid = self.xid(); diff --git a/src/platform/linux/window.rs b/src/platform/linux/window.rs index 318c98a..c6c0321 100644 --- a/src/platform/linux/window.rs +++ b/src/platform/linux/window.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT use raw_window_handle::{HasWindowHandle, RawWindowHandle}; -use std::os::raw::{c_int, c_uint, c_ulong}; +use std::os::raw::c_ulong; use tauri_runtime::ProgressBarState; use tauri_utils::config::Color; @@ -28,33 +28,8 @@ impl AppWindow { } } - /// Whether the X11 keyboard focus sits on this window or a descendant. Tells a - /// real focus loss apart from the `FocusOut`/`NotifyInferior` that - /// [`AppWebview::take_input_focus`] causes. - pub(crate) fn owns_input_focus(&self) -> bool { - let xid = self.xid(); - - super::utils::with_cef_display(false, |xlib, display| unsafe { - let mut focus: x11_dl::xlib::Window = 0; - let mut revert_to: c_int = 0; - if (xlib.XGetInputFocus)(display, &mut focus, &mut revert_to) == 0 { - return false; - } - - // `None` and `PointerRoot` are not real windows. - if focus <= x11_dl::xlib::PointerRoot as x11_dl::xlib::Window { - return false; - } - - let mut current = focus; - while current != 0 { - if current == xid { - return true; - } - current = parent_window(xlib, display, current); - } - false - }) + pub(crate) fn raise_native(&self) { + super::utils::activate_window(self.xid()); } pub(crate) fn set_enabled(&self, enabled: bool) { @@ -105,33 +80,3 @@ impl AppWindow { taskbar::set_progress_bar(state); } } - -fn parent_window( - xlib: &x11_dl::xlib::Xlib, - display: *mut x11_dl::xlib::Display, - window: x11_dl::xlib::Window, -) -> x11_dl::xlib::Window { - let mut root: x11_dl::xlib::Window = 0; - let mut parent: x11_dl::xlib::Window = 0; - let mut children: *mut x11_dl::xlib::Window = std::ptr::null_mut(); - let mut child_count: c_uint = 0; - - unsafe { - if (xlib.XQueryTree)( - display, - window, - &mut root, - &mut parent, - &mut children, - &mut child_count, - ) == 0 - { - return 0; - } - if !children.is_null() { - (xlib.XFree)(children.cast()); - } - } - - parent -} diff --git a/src/platform/macos/application.rs b/src/platform/macos/application.rs index f40c094..17d3ca3 100644 --- a/src/platform/macos/application.rs +++ b/src/platform/macos/application.rs @@ -13,6 +13,7 @@ use objc2::{ msg_send, rc::Retained, runtime::{AnyObject, Bool, ProtocolObject}, + sel, }; use objc2_app_kit::{ NSApp, NSApplication, NSApplicationActivationOptions, NSApplicationDelegate, @@ -143,14 +144,14 @@ define_class!( value: Option<&AnyObject>, attribute: Option<&NSString>, ) { - if let (Some(value), Some(attribute)) = (value, attribute) { - if attribute.to_string() == "AXEnhancedUserInterface" { - let int_value: std::ffi::c_int = unsafe { msg_send![value, intValue] }; - if let Some(delegate) = self.delegate() { - delegate.emit(AppDelegateEvent::AccessibilityChanged { - enabled: int_value == 1, - }); - } + if let (Some(value), Some(attribute)) = (value, attribute) && + attribute.to_string() == "AXEnhancedUserInterface" + { + let int_value: std::ffi::c_int = unsafe { msg_send![value, intValue] }; + if let Some(delegate) = self.delegate() { + delegate.emit(AppDelegateEvent::AccessibilityChanged { + enabled: int_value == 1, + }); } } @@ -221,9 +222,40 @@ impl CefWinitApplication { } } +/// Make this application the active one. +/// +/// `activateIgnoringOtherApps:` is deprecated since macOS 14 and is largely +/// ignored there: activation became cooperative, so an app that asks the old +/// way while another app owns the foreground simply stays behind. `-[NSApplication +/// activate]` is the replacement, so prefer it whenever the running system has +/// it and keep the legacy call for older releases. +pub(crate) fn activate_application() { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + + let app = NSApplication::sharedApplication(mtm); + if app.respondsToSelector(sel!(activate)) { + app.activate(); + } else { + #[allow(deprecated)] + app.activateIgnoringOtherApps(true); + } +} + +/// Creates the CEF-compatible AppKit application before displaying native startup UI. +/// +/// Call this on the main thread before any code creates an `NSApplication`, for example +/// before presenting a recovery dialog. It does not initialize CEF, its event loop, or +/// a browser profile. Repeated calls, including later runtime initialization, are safe. +/// +/// # Panics +/// +/// Panics outside the main thread or if another application class already owns the +/// AppKit singleton. pub fn setup_application() { - let _ = CefWinitApplication::shared_application(); let mtm = MainThreadMarker::new().expect("macOS application must start on the main thread"); + let _ = CefWinitApplication::shared_application(); assert!(NSApp(mtm).isKindOfClass(CefWinitApplication::class())); } diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index 4cbeea3..f0d44af 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -mod appkit_state; mod application; mod dock; mod event_loop; @@ -12,6 +11,7 @@ mod utils; mod webview; mod window; -pub(crate) use appkit_state::AppkitState; pub use application::setup_application; -pub(crate) use application::{AppDelegate, AppDelegateEvent, set_application_event_handler}; +pub(crate) use application::{ + AppDelegate, AppDelegateEvent, activate_application, set_application_event_handler, +}; diff --git a/src/platform/macos/utils.rs b/src/platform/macos/utils.rs index 6c941cf..e56c2f4 100644 --- a/src/platform/macos/utils.rs +++ b/src/platform/macos/utils.rs @@ -14,7 +14,6 @@ use objc2_app_kit::NSColor; use objc2_application_services::{ ProcessApplicationTransformState, TransformProcessType, kCurrentProcess, }; -use objc2_foundation::NSValue; use tauri_utils::config::Color; #[repr(C)] @@ -52,31 +51,27 @@ pub fn instant_epoch() -> Instant { *INSTANT_EPOCH.get_or_init(Instant::now) } -pub(crate) fn set_associated_data(object: &O, key: *const c_void, data: *const T) { - let value: Retained = NSValue::new(data.cast::()).into(); +pub(crate) fn set_associated_object( + object: &O, + key: *const c_void, + value: &AnyObject, +) { unsafe { objc_setAssociatedObject( object as *const O as *mut AnyObject, key, - Retained::as_ptr(&value) as *mut AnyObject, + value as *const AnyObject as *mut AnyObject, OBJC_ASSOCIATION_RETAIN_NONATOMIC, ); } } -pub(crate) unsafe fn associated_data( +pub(crate) fn associated_object( object: &O, key: *const c_void, -) -> Option<&T> { +) -> Option> { let value = unsafe { objc_getAssociatedObject(object as *const O as *const AnyObject, key) }; - if value.is_null() { - return None; - } - - let data = unsafe { (*(value as *const NSValue)).get::<*const c_void>() }; - if data.is_null() { - return None; - } - - Some(unsafe { &*(data as *const T) }) + // SAFETY: the association is only ever written by `set_associated_object`, + // which stores a valid object pointer under this key. + unsafe { Retained::retain(value.cast_mut()) } } diff --git a/src/platform/macos/webview.rs b/src/platform/macos/webview.rs index 990f533..aa72f93 100644 --- a/src/platform/macos/webview.rs +++ b/src/platform/macos/webview.rs @@ -14,14 +14,25 @@ use crate::{webview::AppWebview, window::AppWindow}; use super::utils; impl AppWebview { - pub(crate) fn take_input_focus(&self) { - let _ = self; + fn try_nsview(&self) -> Option> { + let view = self.host.window_handle().cast::(); + // CEF owns this NSView for the browser lifetime; retaining it protects the + // synchronous UI-thread observation. A destroyed native view is unavailable. + unsafe { Retained::::retain(view) } } pub(crate) fn nsview(&self) -> Retained { - let handle = self.host.window_handle(); - let view = handle.cast::(); - unsafe { Retained::::retain(view).expect("failed to retain NSView") } + self.try_nsview().expect("failed to retain NSView") + } + + pub(crate) fn native_parent_matches(&self, parent: &AppWindow) -> Option { + let view = self.try_nsview()?; + let superview = unsafe { view.superview() }; + Some(superview.as_deref() == Some(&*parent.nsview())) + } + + pub(crate) fn native_visible(&self) -> Option { + Some(!self.try_nsview()?.isHiddenOrHasHiddenAncestor()) } pub(crate) fn set_background_color(&self, color: Option) { @@ -42,7 +53,7 @@ impl AppWebview { } pub(crate) fn bounds(&self) -> Option { - let nsview = self.nsview(); + let nsview = self.try_nsview()?; let parent = unsafe { nsview.superview()? }; let parent_frame = parent.frame(); @@ -76,9 +87,14 @@ impl AppWebview { nsview.setHidden(!visible); } - pub(crate) fn destroy_native(&self) { - let nsview = self.nsview(); - nsview.removeFromSuperview(); + /// Destroys CEF's own view for this browser, completing a close that + /// `do_close` took over. + /// + /// The superview holds the only strong reference to that view, so dropping it + /// deallocates the view — and its `dealloc` is what reports `WindowDestroyed` + /// back to CEF. + pub(crate) fn destroy_host_window(&self) { + self.nsview().removeFromSuperview(); } pub(crate) fn apply_physical_bounds(&self, scale: f64, x: i32, y: i32, width: i32, height: i32) { diff --git a/src/platform/macos/window.rs b/src/platform/macos/window.rs index 9eb9a45..f1ccf9b 100644 --- a/src/platform/macos/window.rs +++ b/src/platform/macos/window.rs @@ -2,33 +2,33 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::{cell::Cell, mem, ptr}; +use std::{cell::Cell, ffi::c_void}; -use dispatch2::MainThreadBound; use objc2::{ + DefinedClass, MainThreadOnly, define_class, rc::Retained, - runtime::{Imp, Sel}, + runtime::{NSObject, NSObjectProtocol}, sel, }; use objc2_app_kit::{ NSBackingStoreType, NSColor, NSView, NSWindow, NSWindowButton, NSWindowCollectionBehavior, - NSWindowStyleMask, + NSWindowDidBecomeKeyNotification, NSWindowDidChangeBackingPropertiesNotification, + NSWindowDidChangeScreenNotification, NSWindowDidDeminiaturizeNotification, + NSWindowDidEndLiveResizeNotification, NSWindowDidExitFullScreenNotification, + NSWindowDidResizeNotification, NSWindowStyleMask, +}; +use objc2_foundation::{ + MainThreadMarker, NSNotification, NSNotificationCenter, NSObjectNSDelayedPerforming, NSPoint, }; -use objc2_foundation::{MainThreadMarker, NSPoint, NSRect}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use tauri_runtime::dpi::Position; use tauri_utils::{TitleBarStyle, config::Color}; use crate::window::AppWindow; -use super::{AppkitState, utils}; +use super::utils; impl AppWindow { - pub(crate) fn owns_input_focus(&self) -> bool { - let _ = self; - false - } - pub(crate) fn raw_cef_handle(&self) -> cef::sys::cef_window_handle_t { let nsview = self.nsview(); Retained::as_ptr(&nsview).cast_mut().cast() @@ -77,7 +77,7 @@ impl AppWindow { ) }; sheet.setAlphaValue(0.5); - (&*nswindow).beginSheet_completionHandler(&*sheet, None); + nswindow.beginSheet_completionHandler(&sheet, None); } } @@ -96,22 +96,37 @@ impl AppWindow { }; let pos = position.to_logical::(nswindow.backingScaleFactor()); - if let Ok(mut state) = self.appkit_state.write() { - let pos = NSPoint::new(pos.x, pos.y); - state.traffic_light_position = Some(pos); - } + let pos = NSPoint::new(pos.x, pos.y); inset_traffic_lights(&nswindow, pos.x, pos.y); - swizzle_draw_rect(&nsview); + observe_traffic_light_resets(&nswindow, pos); } - pub(crate) fn associate_appkit_state(&self) { + /// Restore the traffic light position after a window appearance change. + /// + /// Changing the appearance rebuilds the titlebar like a geometry change does, + /// but posts no window notification, so the theme paths have to ask for it. + /// AppKit rebuilds *after* the new appearance is observable, so the inset is + /// restored on the next run loop turn — restoring it inline is overwritten. + pub(crate) fn reapply_traffic_light_position_after_appearance_change(&self) { let nsview = self.nsview(); let Some(nswindow) = nsview.window() else { return; }; + let Some(observer) = traffic_light_observer(&nswindow) else { + // No traffic light position configured for this window. + return; + }; - AppkitState::associate(&self.appkit_state, &nswindow); + // SAFETY: `observer` implements the selector and takes the window as its + // argument. The run loop keeps both alive until it fires. + unsafe { + observer.performSelector_withObject_afterDelay( + sel!(reapplyTrafficLightPosition:), + Some(&nswindow), + 0.0, + ); + } } pub(crate) fn set_title_bar_style(&self, style: TitleBarStyle) { @@ -199,57 +214,122 @@ fn inset_traffic_lights(nswindow: &NSWindow, x: f64, y: f64) { } } -type DrawRect = extern "C-unwind" fn(&NSView, Sel, NSRect); - -static ORIGINAL_DRAW_RECT: MainThreadBound>> = { - // SAFETY: Creating in a `const` context, where there is no concept of the main thread. - MainThreadBound::new(Cell::new(None), unsafe { - MainThreadMarker::new_unchecked() - }) -}; +/// AppKit rebuilds the titlebar whenever the window geometry changes, which +/// restores the stock window-button layout and drops the inset applied by +/// [`inset_traffic_lights`]. The reset has to be undone after AppKit finished +/// laying the titlebar out again, so the inset is reapplied from the window +/// notifications that follow the relayout. +/// +/// Reapplying from a view frame observer instead does not work: that fires in +/// the middle of AppKit's own layout pass, and AppKit overwrites the geometry +/// afterwards. +fn observe_traffic_light_resets(nswindow: &NSWindow, position: NSPoint) { + // The observer owns the position it applies, so it stays valid for exactly as + // long as it can receive notifications, and a window only ever registers one. + if let Some(observer) = traffic_light_observer(nswindow) { + observer.ivars().position.set(position); + return; + } -extern "C-unwind" fn draw_rect(view: &NSView, sel: Sel, rect: NSRect) { - let mtm = MainThreadMarker::from(view); - let original = ORIGINAL_DRAW_RECT - .get(mtm) - .get() - .expect("no existing drawRect: handler set"); + let Some(mtm) = MainThreadMarker::new() else { + return; + }; - original(view, sel, rect); + let observer = TrafficLightObserver::new(mtm, position); + let center = NSNotificationCenter::defaultCenter(); + for name in unsafe { + [ + NSWindowDidResizeNotification, + NSWindowDidEndLiveResizeNotification, + NSWindowDidExitFullScreenNotification, + NSWindowDidDeminiaturizeNotification, + NSWindowDidChangeScreenNotification, + NSWindowDidChangeBackingPropertiesNotification, + NSWindowDidBecomeKeyNotification, + ] + } { + // SAFETY: `observer` implements the selector, and the notifications are + // observed for a single `NSWindow`, which is what the handler expects as + // the notification object. + unsafe { + center.addObserver_selector_name_object( + &observer, + sel!(handleWindowNotification:), + Some(name), + Some(nswindow), + ); + } + } - post_draw_rect(view, rect); + set_traffic_light_observer(nswindow, &observer); } -fn post_draw_rect(view: &NSView, _rect: NSRect) { - let Some(nswindow) = view.window() else { +fn reapply_traffic_light_position(nswindow: &NSWindow, position: NSPoint) { + // In fullscreen the window buttons are owned by the auto-hiding titlebar + // overlay rather than by the window's own titlebar, so insetting them there + // would move them out of the overlay. The relayout that follows leaving + // fullscreen posts a resize notification, which restores the inset. + if nswindow.styleMask().contains(NSWindowStyleMask::FullScreen) { return; - }; + } - let Some(state) = AppkitState::from_window(&nswindow) else { - return; - }; - let Ok(state) = state.read() else { - return; - }; + inset_traffic_lights(nswindow, position.x, position.y); +} - if let Some(pos) = state.traffic_light_position { - inset_traffic_lights(&nswindow, pos.x, pos.y); - } +#[derive(Default)] +struct TrafficLightObserverIvars { + position: Cell, } -fn swizzle_draw_rect(nsview: &NSView) { - let mtm = MainThreadMarker::from(nsview); - let class = nsview.class(); - let Some(method) = class.instance_method(sel!(drawRect:)) else { - return; - }; +define_class!( + #[unsafe(super(NSObject))] + #[name = "TauriCefTrafficLightObserver"] + #[ivars = TrafficLightObserverIvars] + #[thread_kind = MainThreadOnly] + struct TrafficLightObserver; - let overridden = unsafe { mem::transmute::(draw_rect) }; - if ptr::fn_addr_eq(overridden, method.implementation()) { - return; + unsafe impl NSObjectProtocol for TrafficLightObserver {} + + impl TrafficLightObserver { + #[unsafe(method(handleWindowNotification:))] + fn handle_window_notification(&self, notification: &NSNotification) { + let Some(object) = notification.object() else { + return; + }; + // SAFETY: the observer is only registered for `NSWindow` notifications + // with a window as the notification object. + let nswindow = unsafe { Retained::cast_unchecked::(object) }; + reapply_traffic_light_position(&nswindow, self.ivars().position.get()); + } + + #[unsafe(method(reapplyTrafficLightPosition:))] + fn reapply_traffic_light_position_deferred(&self, nswindow: &NSWindow) { + reapply_traffic_light_position(nswindow, self.ivars().position.get()); + } } +); + +impl TrafficLightObserver { + fn new(mtm: MainThreadMarker, position: NSPoint) -> Retained { + let observer = Self::alloc(mtm).set_ivars(TrafficLightObserverIvars { + position: Cell::new(position), + }); + unsafe { objc2::msg_send![super(observer), init] } + } +} + +fn traffic_light_observer_key() -> *const c_void { + static TRAFFIC_LIGHT_OBSERVER_KEY: u8 = 0; + &TRAFFIC_LIGHT_OBSERVER_KEY as *const u8 as *const c_void +} + +fn traffic_light_observer(nswindow: &NSWindow) -> Option> { + let observer = utils::associated_object(nswindow, traffic_light_observer_key())?; + // SAFETY: the key is private to this module, and `set_traffic_light_observer` + // is the only writer. + Some(unsafe { Retained::cast_unchecked::(observer) }) +} - let original = unsafe { method.set_implementation(overridden) }; - let original = unsafe { mem::transmute::(original) }; - ORIGINAL_DRAW_RECT.get(mtm).set(Some(original)); +fn set_traffic_light_observer(nswindow: &NSWindow, observer: &TrafficLightObserver) { + utils::set_associated_object(nswindow, traffic_light_observer_key(), observer); } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 28f4127..ea35617 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -15,7 +15,7 @@ pub mod macos; target_os = "netbsd", target_os = "openbsd" ))] -pub mod linux; +mod linux; use tauri_runtime::dpi::PhysicalRect; use winit::monitor::MonitorHandle; diff --git a/src/platform/windows/icon.rs b/src/platform/windows/icon.rs index 4a9b1fc..322d51e 100644 --- a/src/platform/windows/icon.rs +++ b/src/platform/windows/icon.rs @@ -14,7 +14,7 @@ pub fn icon_to_hicon(icon: Icon<'static>) -> Option { } let mut and_mask = Vec::with_capacity(width as usize * height as usize); - for pixel in rgba.chunks_exact_mut(4) { + for pixel in rgba.as_chunks_mut::<4>().0 { and_mask.push(pixel[3].wrapping_sub(u8::MAX)); pixel.swap(0, 2); } diff --git a/src/platform/windows/webview.rs b/src/platform/windows/webview.rs index f63bbc8..ebf8e8d 100644 --- a/src/platform/windows/webview.rs +++ b/src/platform/windows/webview.rs @@ -6,19 +6,46 @@ use cef::ImplBrowserHost; use tauri_runtime::dpi::{PhysicalPosition, PhysicalSize, Rect}; use tauri_utils::config::Color; use windows::Win32::{ - Foundation::{HWND, POINT, RECT}, + Foundation::{ERROR_SUCCESS, HWND, LPARAM, LRESULT, POINT, RECT, SetLastError, WPARAM}, Graphics::Gdi::MapWindowPoints, + UI::Shell::{DefSubclassProc, SetWindowSubclass}, UI::WindowsAndMessaging::{ - DestroyWindow, GetParent, GetWindowRect, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOZORDER, - SetParent, SetWindowPos, ShowWindow, + DestroyWindow, GetParent, GetWindowRect, HWND_TOP, IsWindow, IsWindowVisible, SW_HIDE, SW_SHOW, + SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetParent, SetWindowPos, ShowWindow, + WINDOWPOS, WM_WINDOWPOSCHANGING, }, }; use crate::{webview::AppWebview, window::AppWindow}; impl AppWebview { - pub(crate) fn take_input_focus(&self) { - let _ = self; + pub(crate) fn native_parent_matches(&self, parent: &AppWindow) -> Option { + let hwnd = self.hwnd(); + unsafe { + if !IsWindow(Some(hwnd)).as_bool() { + return None; + } + // `GetParent` returns NULL both for a window that has no parent and when + // the call itself fails, and the binding maps that NULL to an `Err` + // carrying the last error — so clear it first to tell the two apart. + SetLastError(ERROR_SUCCESS); + match GetParent(hwnd) { + Ok(native_parent) => Some(native_parent == parent.hwnd()), + // `ERROR_SUCCESS`: the window really has no parent, which is an + // observation of the relationship and not a failure to establish it. + Err(err) if err.code().is_ok() => Some(false), + Err(_) => None, + } + } + } + + pub(crate) fn native_visible(&self) -> Option { + let hwnd = self.hwnd(); + unsafe { + IsWindow(Some(hwnd)) + .as_bool() + .then(|| IsWindowVisible(hwnd).as_bool()) + } } pub(crate) fn hwnd(&self) -> HWND { @@ -76,10 +103,77 @@ impl AppWebview { let _ = unsafe { ShowWindow(self.hwnd(), if visible { SW_SHOW } else { SW_HIDE }) }; } - pub(crate) fn destroy_native(&self) { + /// Destroys CEF's own window for this browser, completing a close that + /// `do_close` took over. CEF's browser window procedure reports + /// `WindowDestroyed` back to CEF on `WM_NCDESTROY`. + pub(crate) fn destroy_host_window(&self) { let _ = unsafe { DestroyWindow(self.hwnd()) }; } + const PIN_Z_ORDER_SUBCLASS_ID: usize = 124; + /// `dwRefData` of the pin subclass: whether it is currently vetoing. + const Z_ORDER_UNPINNED: usize = 0; + const Z_ORDER_PINNED: usize = 1; + + /// Refuses every z-order change to this webview while the pin is engaged. + unsafe extern "system" fn pin_z_order_subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _subclass_id: usize, + pinned: usize, + ) -> LRESULT { + unsafe { + if pinned == Self::Z_ORDER_PINNED && msg == WM_WINDOWPOSCHANGING && lparam.0 != 0 { + let window_pos = &mut *(lparam.0 as *mut WINDOWPOS); + window_pos.flags |= SWP_NOZORDER; + } + + DefSubclassProc(hwnd, msg, wparam, lparam) + } + } + + /// Engages or disengages the z-order pin. + /// + /// Re-installing the same proc under the same id does not chain a second + /// subclass, it just updates `dwRefData` — so this both installs the pin the + /// first time and toggles it afterwards. + fn set_z_order_pinned(&self, pinned: bool) { + let _ = unsafe { + SetWindowSubclass( + self.hwnd(), + Some(Self::pin_z_order_subclass_proc), + Self::PIN_Z_ORDER_SUBCLASS_ID, + if pinned { + Self::Z_ORDER_PINNED + } else { + Self::Z_ORDER_UNPINNED + }, + ) + }; + } + + /// Raises this webview above its siblings and pins it there, so nothing but + /// this runtime can move it again. See [`Self::pin_z_order_subclass_proc`]. + pub(crate) fn raise_to_top(&self) { + self.set_z_order_pinned(false); + + let _ = unsafe { + SetWindowPos( + self.hwnd(), + Some(HWND_TOP), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ) + }; + + self.set_z_order_pinned(true); + } + pub(crate) fn apply_physical_bounds(&self, _scale: f64, x: i32, y: i32, width: i32, height: i32) { unsafe { let _ = SetWindowPos( diff --git a/src/platform/windows/window.rs b/src/platform/windows/window.rs index c5f152e..d36cfd0 100644 --- a/src/platform/windows/window.rs +++ b/src/platform/windows/window.rs @@ -26,11 +26,6 @@ use crate::{window::AppWindow, window_handle::SoftbufferWindowHandle}; use super::icon::icon_to_hicon; impl AppWindow { - pub(crate) fn owns_input_focus(&self) -> bool { - let _ = self; - false - } - pub(crate) fn raw_cef_handle(&self) -> cef::sys::cef_window_handle_t { cef::sys::HWND(self.hwnd().0 as *mut _) } @@ -103,12 +98,18 @@ impl AppWindow { self.window.request_redraw(); } + /// Paints the window's own background over everything its webviews do not + /// cover — the window is `WS_CLIPCHILDREN`, so live webviews clip themselves + /// out of this paint. + /// + /// This is the only thing that ever paints the window itself: winit registers + /// its window class without a background brush, and Windows leaves the pixels + /// a destroyed child window drew last sitting in the parent's client area. A + /// closed webview would otherwise keep showing its final frame — visible, and + /// backed by no window at all — for as long as the window lived. Destroying + /// the webview's window invalidates the area it covered, so painting on every + /// redraw is what clears it. pub(crate) fn draw_background_surface(&mut self) { - if !self.attrs.inner.transparent && self.attrs.background_color.is_none() { - self.background_surface = None; - return; - } - let size = self.window.surface_size(); let (Some(width), Some(height)) = (NonZeroU32::new(size.width), NonZeroU32::new(size.height)) else { @@ -132,11 +133,13 @@ impl AppWindow { return; }; - let color = self - .attrs - .background_color - .map(|Color(r, g, b, _)| (b as u32) | ((g as u32) << 8) | ((r as u32) << 16)) - .unwrap_or(0); + let color = match self.attrs.background_color { + Some(Color(r, g, b, _)) => (b as u32) | ((g as u32) << 8) | ((r as u32) << 16), + // A transparent window paints nothing so the desktop shows through, while + // an ordinary one falls back to the opaque white a blank browser shows. + None if self.attrs.inner.transparent => 0, + None => 0x00ff_ffff, + }; if surface.resize(width, height).is_ok() && let Ok(mut buffer) = surface.buffer_mut() diff --git a/src/popup.rs b/src/popup.rs new file mode 100644 index 0000000..b9ef80b --- /dev/null +++ b/src/popup.rs @@ -0,0 +1,467 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! CEF-owned popup lifetimes. CEF keeps its native window and opener semantics; +//! the runtime owns observation, teardown, and the separate protocol observer, +//! which serves the runtime's own dialog observation and no app callback. + +use crate::{ + FrameNavigationState, NativeWindowToken, + webview::{Webview, WebviewSnapshot, add_dev_tools_observer}, +}; +use cef::*; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use tauri_runtime::dpi::{LogicalPosition, LogicalSize, Rect}; + +const MAX_POPUPS: usize = 128; + +#[derive(Clone)] +pub(crate) struct PopupRequest { + pub(crate) opener: FrameNavigationState, + popup_id: i32, + identity: Arc<()>, +} +impl PopupRequest { + pub(crate) fn is_same(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.identity, &other.identity) + } +} + +struct Popup { + browser: Browser, + state: FrameNavigationState, + opener: FrameNavigationState, + closing: AtomicBool, + _observer: Registration, + dialogs: crate::dialog::DialogState, +} + +pub(crate) struct PopupFamily { + root: FrameNavigationState, + closing: AtomicBool, + popups: Mutex>>, + pending: Mutex>, + windows: Mutex>, +} + +impl PopupFamily { + pub(crate) fn new(root: FrameNavigationState) -> Self { + Self { + root, + closing: AtomicBool::new(false), + popups: Mutex::default(), + pending: Mutex::default(), + windows: Mutex::default(), + } + } + + pub(crate) fn admits(&self, opener: &FrameNavigationState, browser_id: i32) -> bool { + if self.closing.load(Ordering::Acquire) || !opener.has_browser_id(browser_id) { + return false; + } + let Ok(popups) = self.popups.lock() else { + return false; + }; + popups.len() < MAX_POPUPS + && (self.root.is_same_browser(opener) + || popups.iter().any(|popup| { + !popup.closing.load(Ordering::Acquire) && popup.state.is_same_browser(opener) + })) + } + + pub(crate) fn reserve( + &self, + opener: &FrameNavigationState, + browser_id: i32, + popup_id: i32, + ) -> Option { + if !self.admits(opener, browser_id) { + return None; + } + let mut pending = self.pending.lock().ok()?; + if pending.len() + self.popups.lock().ok()?.len() >= MAX_POPUPS + || pending + .iter() + .any(|request| request.popup_id == popup_id && request.opener.is_same_browser(opener)) + { + return None; + } + let request = PopupRequest { + opener: opener.clone(), + popup_id, + identity: Arc::new(()), + }; + pending.push(request.clone()); + Some(request) + } + + pub(crate) fn abort(&self, opener: &FrameNavigationState, popup_id: i32) -> Option { + let mut pending = self.pending.lock().ok()?; + let index = pending + .iter() + .position(|request| request.popup_id == popup_id && request.opener.is_same_browser(opener))?; + Some(pending.remove(index)) + } + + pub(crate) fn created( + &self, + browser: &Browser, + request: &PopupRequest, + state: &FrameNavigationState, + ) { + let reserved = self + .pending + .lock() + .map(|mut pending| { + let found = pending.iter().any(|entry| entry.is_same(request)); + pending.retain(|entry| !entry.is_same(request)); + found + }) + .unwrap_or(false); + let opener = &request.opener; + let Some(host) = browser.host() else { + return; + }; + if !reserved || !self.admits(opener, host.opener_identifier()) { + state.close(); + host.close_browser(1); + return; + } + let dialogs = crate::dialog::DialogState::new(state.clone()); + // The runtime's own observer, on a handler list of this popup's own that + // stays empty: it exists for the internal dialog observation below, never + // to feed the opener's app observers. A popup is a separate native browser + // navigating wherever its own content goes — an SSO or OAuth window is the + // standing case — and a `DevToolsProtocol` notification carries that page's + // content, its network activity and its dialog messages, which the web + // platform itself denies the opener across origins. An app observes popups + // without their content through `Webview::popups`. + let Some(observer) = + add_dev_tools_observer(browser, Arc::default(), Arc::default(), dialogs.clone()) + else { + state.close(); + host.close_browser(1); + return; + }; + let popup = Arc::new(Popup { + browser: browser.clone(), + state: state.clone(), + opener: opener.clone(), + closing: AtomicBool::new(false), + _observer: observer, + dialogs, + }); + if let Ok(mut popups) = self.popups.lock() { + popups.push(popup); + } else { + state.close(); + host.close_browser(1); + } + } + + /// Revoke descendants before asking CEF to close them. The native close + /// callbacks remove their browser references; no family lock crosses CEF. + pub(crate) fn closed(&self, state: &FrameNavigationState, browser_id: i32) { + if !state.has_browser_id(browser_id) { + return; + } + state.close(); + let root_closed = self.root.is_same_browser(state); + if root_closed { + self.closing.store(true, Ordering::Release); + } + let descendants = { + let Ok(mut popups) = self.popups.lock() else { + return; + }; + popups.retain(|popup| !popup.state.is_same_browser(state)); + revoke_descendants( + state, + root_closed, + popups + .iter() + .enumerate() + .map(|(index, popup)| (index, &popup.state, &popup.opener, &popup.closing)), + ) + .into_iter() + .map(|index| Arc::clone(&popups[index])) + .collect::>() + }; + for popup in descendants { + if popup.browser.is_valid() != 0 + && let Some(host) = popup.browser.host() + { + host.close_browser(1); + } + } + } + + /// Window close and app shutdown own the whole family, so teardown never + /// depends on the root having observed a native browser identity. `closed` + /// keeps its exact-identity guard for the per-browser native callback; a root + /// that was exhausted before its first frame event would otherwise leave every + /// popup open and the event loop waiting on them forever. + pub(crate) fn close_all(&self) { + self.closing.store(true, Ordering::Release); + self.root.close(); + let descendants = { + let Ok(popups) = self.popups.lock() else { + return; + }; + revoke_descendants( + &self.root, + true, + popups + .iter() + .enumerate() + .map(|(index, popup)| (index, &popup.state, &popup.opener, &popup.closing)), + ) + .into_iter() + .map(|index| Arc::clone(&popups[index])) + .collect::>() + }; + for popup in descendants { + if popup.browser.is_valid() != 0 + && let Some(host) = popup.browser.host() + { + host.close_browser(1); + } + } + } + + /// A revoked family lost its root and admits no further popup, so nothing it + /// still has reserved can ever be created. CEF does not always report the + /// abort of a popup it discards, so the reservations of a revoked family are + /// what the event loop would otherwise wait on forever. + pub(crate) fn is_revoked(&self) -> bool { + self.closing.load(Ordering::Acquire) + } + + pub(crate) fn observe(&self) -> Vec { + if self.closing.load(Ordering::Acquire) { + return Vec::new(); + } + let popups = self + .popups + .lock() + .map(|popups| popups.clone()) + .unwrap_or_default(); + // Several popup browser tabs may share one CEF window. Window identity is + // owned by the family, never minted independently for each tab. + let mut windows = self + .windows + .lock() + .map(|windows| windows.clone()) + .unwrap_or_default(); + windows.retain(|(window, _)| window.is_valid() != 0 && window.is_closed() == 0); + let observations = popups + .iter() + .filter_map(|popup| { + if popup.closing.load(Ordering::Acquire) || popup.browser.is_valid() == 0 { + return None; + } + let mut browser = popup.browser.clone(); + let view = + browser_view_get_for_browser(Some(&mut browser)).filter(|view| view.is_valid() != 0); + let observed_window = view + .as_ref() + .and_then(ImplView::window) + .filter(|window| window.is_valid() != 0 && window.is_closed() == 0); + let window = observed_window.as_ref().and_then(|window| { + if let Some((_, token)) = windows + .iter() + .find(|(previous, _)| window.is_same(Some(&mut View::from(previous))) != 0) + { + return Some(token.clone()); + } + if windows.len() >= MAX_POPUPS { + return None; + } + let token = NativeWindowToken::new(); + windows.push((window.clone(), token.clone())); + Some(token) + }); + let bounds = view.as_ref().map(|view| { + let rect = view.bounds(); + Rect { + position: LogicalPosition::new(rect.x, rect.y).into(), + size: LogicalSize::new(rect.width, rect.height).into(), + } + }); + let visible = view + .as_ref() + .zip(observed_window.as_ref()) + .map(|(view, window)| { + view.is_drawn() != 0 && window.is_visible() != 0 && window.is_minimized() == 0 + }); + let document = popup.state.observe_document(&browser); + let dialogs = popup.dialogs.snapshot(document.as_ref()); + let snapshot = WebviewSnapshot { + browser_id: browser.identifier(), + dialogs, + document, + window_label: None, + window, + // CEF owns a popup's native window and the runtime never reparents + // it, so there is no independently observed parent to check the view + // against: the reported window is the one the view itself named. + // The relationship is therefore never established here, and reporting + // a match would hand a caller gating a native effect on `Some(true)` + // an assertion nothing verified. + parent_matches: None, + bounds, + visible, + }; + let mut native = Webview::new(browser, snapshot, popup.state.clone()); + native.set_opener(popup.opener.clone()); + Some(native) + }) + .collect(); + if let Ok(mut retained) = self.windows.lock() { + *retained = windows; + } + observations + } +} + +/// The graph walk is independent of CEF calls. Revoke every descendant before +/// returning handles for native close, including when callbacks arrive out of order. +fn revoke_descendants<'a>( + state: &FrameNavigationState, + root_closed: bool, + nodes: impl Iterator< + Item = ( + usize, + &'a FrameNavigationState, + &'a FrameNavigationState, + &'a AtomicBool, + ), + > + Clone, +) -> Vec { + let mut revoked = vec![state.clone()]; + let mut indices = Vec::new(); + loop { + let before = indices.len(); + for (index, state, opener, closing) in nodes.clone() { + if !closing.load(Ordering::Acquire) + && (root_closed || revoked.iter().any(|parent| parent.is_same_browser(opener))) + { + closing.store(true, Ordering::Release); + state.close(); + revoked.push(state.clone()); + indices.push(index); + } + } + if indices.len() == before { + return indices; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn created(id: i32) -> FrameNavigationState { + let state = FrameNavigationState::new(); + state.on_frame_event(&crate::FrameEvent { + browser_id: id, + frame_id: "main".into(), + is_main: true, + kind: crate::FrameEventKind::Created, + }); + state + } + #[test] + fn closing_an_opener_revokes_only_its_exact_descendants() { + let root = created(1); + let first = created(2); + let nested = created(3); + let sibling = created(4); + // Deliberately put a descendant before its opener. + let nodes = [ + (nested.clone(), first.clone(), AtomicBool::new(false)), + (first.clone(), root.clone(), AtomicBool::new(false)), + (sibling.clone(), root.clone(), AtomicBool::new(false)), + ]; + let refs = || { + nodes + .iter() + .enumerate() + .map(|(index, (state, opener, closing))| (index, state, opener, closing)) + }; + assert!(revoke_descendants(&created(2), false, refs()).is_empty()); + assert_eq!(revoke_descendants(&first, false, refs()), vec![0]); + assert!(nodes[0].2.load(Ordering::Acquire)); + assert!(!nodes[2].2.load(Ordering::Acquire)); + assert_eq!(revoke_descendants(&root, true, refs()), vec![1, 2]); + assert!(nodes.iter().all(|node| node.2.load(Ordering::Acquire))); + assert!(revoke_descendants(&root, true, refs()).is_empty()); + } + #[test] + fn pending_popups_are_bounded_and_retained_until_exact_creation_or_abort() { + let root = created(1); + let family = PopupFamily::new(root.clone()); + let first = family.reserve(&root, 1, 7).unwrap(); + assert!(family.reserve(&root, 1, 7).is_none()); + assert!(family.abort(&created(1), 7).is_none()); + for id in 8..(7 + MAX_POPUPS as i32) { + assert!(family.reserve(&root, 1, id).is_some()); + } + assert!(family.reserve(&root, 1, 1000).is_none()); + family.closed(&root, 1); + assert_eq!(family.pending.lock().unwrap().len(), MAX_POPUPS); + assert!(family.abort(&root, 7).unwrap().is_same(&first)); + assert!(family.abort(&root, 7).is_none()); + assert!(family.reserve(&root, 1, 1000).is_none()); + } + #[test] + fn teardown_revokes_a_family_still_holding_an_unresolved_reservation() { + let root = created(1); + let family = PopupFamily::new(root.clone()); + // CEF discarded this popup without ever creating or aborting it. + let request = family.reserve(&root, 1, 7).unwrap(); + assert!(!family.is_revoked()); + // A popup's own native close leaves the family live, so the event loop + // keeps waiting for the reservations that can still be created. + family.closed(&created(2), 2); + assert!(!family.is_revoked()); + assert_eq!(family.pending.lock().unwrap().len(), 1); + // Window close and app shutdown own the family outright: once revoked, no + // reservation of it can ever be created, so none may hold the loop open. + family.close_all(); + assert!(family.is_revoked()); + assert!(!family.admits(&request.opener, 1)); + } + #[test] + fn popup_admission_requires_the_live_exact_native_opener() { + let root = created(1); + let family = PopupFamily::new(root.clone()); + assert!(family.admits(&root, 1)); + assert!(!family.admits(&root, 2)); + assert!(!family.admits(&created(1), 1)); + assert!(!family.admits(&FrameNavigationState::new(), 1)); + family.closed(&root, 2); + assert!(family.admits(&root, 1)); + family.closed(&root, 1); + assert!(!family.admits(&root, 1)); + assert!(family.observe().is_empty()); + } + #[test] + fn teardown_closes_a_family_whose_root_never_observed_a_browser_id() { + // A root that never observed a native frame event has no browser identity. + let root = FrameNavigationState::new(); + assert!(!root.has_browser_id(1)); + let family = PopupFamily::new(root.clone()); + // The per-browser native callback cannot match an unobserved identity. + family.closed(&root, 1); + assert!(!family.closing.load(Ordering::Acquire)); + // Window close and app shutdown own the family outright and must not. + family.close_all(); + assert!(family.closing.load(Ordering::Acquire)); + assert!(family.observe().is_empty()); + } +} diff --git a/src/runtime.rs b/src/runtime.rs index ba28362..b0c7b9e 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -44,7 +44,11 @@ use crate::external_message_pump::CefExternalPump; use crate::platform::EventLoopExt; use crate::{ cef_impl::{client as browser_client, ipc, request_handler}, - webview::{self, AppWebview, CefWebviewDispatcher, WebviewMessage, create_webview_detached}, + macros::wrap_with_args, + webview::{ + self, AppWebview, CefWebviewAttributes, CefWebviewDispatcher, Webview, WebviewMessage, + create_webview_detached, + }, window::{ AppWindow, CefWindowDispatcher, WindowMessage, create_window_detached, winit_monitor_to_tauri_monitor, winit_theme_to_tauri_theme, @@ -53,8 +57,6 @@ use crate::{ }; #[cfg(target_os = "macos")] use winit::platform::macos::EventLoopBuilderExtMacOS; -#[cfg(target_os = "linux")] -use winit::platform::wayland::EventLoopBuilderExtWayland; #[cfg(windows)] use winit::platform::windows::EventLoopBuilderExtWindows; #[cfg(any( @@ -66,6 +68,9 @@ use winit::platform::windows::EventLoopBuilderExtWindows; ))] use winit::platform::x11::EventLoopBuilderExtX11; +/// Customizes the CEF settings before initialization, see [`Cef::with_settings`]. +type SettingsCallback = dyn FnOnce(&mut cef::Settings) + Send + Sync; + /// The `cef` crate used by this runtime, re-exported for convenience. /// /// # Stability @@ -75,6 +80,175 @@ use winit::platform::x11::EventLoopBuilderExtX11; /// in minor releases when a known breaking change is discovered. pub use cef; +/// Selects and configures the CEF runtime. +/// +/// Pass it to `tauri::Builder::runtime` to run the application with CEF: +/// +/// ```rust,no_run +/// tauri::Builder::default().runtime( +/// tauri_runtime_cef::Cef::default().command_line_arg("disable-gpu", None::), +/// ); +/// ``` +#[derive(Default)] +pub struct Cef { + command_line_args: Vec<(String, Option)>, + deep_link_schemes: Vec, + cache_path: Option, + api_version: Option, + settings_callback: Option>, +} + +impl fmt::Debug for Cef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Cef") + .field("command_line_args", &self.command_line_args) + .field("deep_link_schemes", &self.deep_link_schemes) + .field("cache_path", &self.cache_path) + .field("api_version", &self.api_version) + .field("settings_callback", &self.settings_callback.is_some()) + .finish() + } +} + +impl Cef { + /// Sets a callback to customize the settings passed to [`cef::initialize`]. + /// + /// If called more than once, only the last callback is used. + #[must_use] + pub fn with_settings(mut self, callback: F) -> Self + where + F: FnOnce(&mut cef::Settings) + Send + Sync + 'static, + { + self.settings_callback = Some(Box::new(callback)); + self + } + + /// Appends one command line argument passed to CEF. + #[must_use] + pub fn command_line_arg, V: Into>( + mut self, + key: K, + value: Option, + ) -> Self { + self + .command_line_args + .push((key.into(), value.map(Into::into))); + self + } + + /// Appends a list of command line arguments passed to CEF. + #[must_use] + pub fn command_line_args, V: Into>( + mut self, + args: impl IntoIterator)>, + ) -> Self { + self + .command_line_args + .extend(args.into_iter().map(|(k, v)| (k.into(), v.map(Into::into)))); + self + } + + /// Appends a list of deep link schemes detected by CEF's on_already_running_app_relaunch hook. + /// + /// Deep links defined by the core deep-link plugin on the Tauri configuration are automatically added. + #[must_use] + pub fn deep_link_schemes>( + mut self, + schemes: impl IntoIterator, + ) -> Self { + self + .deep_link_schemes + .extend(schemes.into_iter().map(Into::into)); + self + } + + /// Directory used for CEF disk cache (`Settings::cache_path`). + /// + /// If unspecified, defaults to `{user cache}/{app identifier}/cef`. + #[must_use] + pub fn root_cache_path>(mut self, path: P) -> Self { + self.cache_path = Some(path.as_ref().to_path_buf()); + self + } + + /// CEF API version this process declares (`cef_api_hash`), defaulting to + /// `cef::sys::CEF_API_VERSION_LAST`. + #[must_use] + pub fn cef_api_version(mut self, version: i32) -> Self { + self.api_version = Some(version); + self + } +} + +impl tauri_runtime::RuntimeInitAttrs for Cef { + type Runtime = CefRuntime; + + fn apply_config(&mut self, config: &tauri_utils::config::Config) -> Result<()> { + if let Some(plugin_config) = config + .plugins + .0 + .get("deep-link") + .and_then(|config| config.get("desktop").cloned()) + { + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum DesktopDeepLinks { + One(tauri_utils::config::DeepLinkProtocol), + List(Vec), + } + + let protocols: DesktopDeepLinks = + serde_json::from_value(plugin_config).map_err(tauri_runtime::Error::Json)?; + let schemes = match protocols { + DesktopDeepLinks::One(protocol) => protocol.schemes, + DesktopDeepLinks::List(protocols) => protocols + .into_iter() + .flat_map(|protocol| protocol.schemes) + .collect(), + }; + + self.deep_link_schemes.extend(schemes); + } + Ok(()) + } +} + +impl From for tauri_runtime::dynamic::DynRuntimeInitAttrs { + fn from(attrs: Cef) -> Self { + Self::new(attrs) + } +} + +/// Information about the CEF webview that requested a new window. +pub struct NewWindowOpener { + source_url: Option, +} + +impl NewWindowOpener { + pub(crate) fn new(source_url: Option) -> Self { + Self { source_url } + } + + /// The opener's main-frame URL at the native popup request, when available. + /// + /// CEF supplies this directly from the callback's browser. Reading a blocking + /// webview getter from that callback can deadlock the UI thread because CEF's + /// external message pump may run outside a winit dispatch callback. + pub fn source_url(&self) -> Option<&url::Url> { + self.source_url.as_ref() + } +} + +impl std::fmt::Debug for NewWindowOpener { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The URL can carry credentials and tokens, so only its presence is shown. + formatter + .debug_struct("NewWindowOpener") + .field("source_url_observed", &self.source_url.is_some()) + .finish() + } +} + #[derive(Clone, Debug)] pub struct EventProxy { context: RuntimeContext, @@ -115,8 +289,7 @@ pub(crate) struct RuntimeContext { /// treated as valid beyond their callback. #[derive(Clone, Copy)] struct MainThreadDispatch { - app: *mut (), - handle: unsafe fn(*mut (), &dyn ActiveEventLoop, Message), + app: *mut WinitCefApp, event_loop: *const dyn ActiveEventLoop, } @@ -157,7 +330,7 @@ impl Default for MainThreadDispatchSlot { } } -pub(crate) struct MainThreadDispatchGuard { +struct MainThreadDispatchGuard { context: RuntimeContext, dispatch: Box>, previous: *mut MainThreadDispatch, @@ -181,11 +354,13 @@ fn handle_main_thread_message( return Err(message); }; - // SAFETY: `install_current_dispatch` stores the currently executing application - // handler and event-loop callback. This only runs on the runtime main thread - // while that callback is active. + // SAFETY: `WinitCefApp::install_current_dispatch` stores pointers to the currently + // executing winit application handler and event-loop callback. This function + // is only called on the runtime main thread while that callback is active. + let app = unsafe { &mut *dispatch.app }; let event_loop = unsafe { &*dispatch.event_loop }; - unsafe { (dispatch.handle)(dispatch.app, event_loop, message) }; + + app.handle_message(event_loop, message); Ok(()) } @@ -197,25 +372,6 @@ impl fmt::Debug for RuntimeContext { } impl RuntimeContext { - pub(crate) fn install_current_dispatch( - &self, - app: *mut (), - handle: unsafe fn(*mut (), &dyn ActiveEventLoop, Message), - event_loop: &dyn ActiveEventLoop, - ) -> MainThreadDispatchGuard { - let mut dispatch = Box::new(MainThreadDispatch { - app, - handle, - event_loop: event_loop as *const _, - }); - let previous = self.current_dispatch.install(dispatch.as_mut()); - MainThreadDispatchGuard { - context: self.clone(), - dispatch, - previous, - } - } - pub(crate) fn send_message(&self, message: Message) -> Result<()> { let message = if self.is_main_thread() { match handle_main_thread_message(self, message) { @@ -278,6 +434,18 @@ pub(crate) type AfterWindowCreationCallback = Box Fn(RawWindow<'a>) pub(crate) enum Message { EventLoop(EventLoopMessage), BrowserClosed(WindowId, u32), + PopupPending(crate::popup::PopupRequest, Arc), + PopupCreated( + crate::popup::PopupRequest, + i32, + Arc, + ), + PopupAborted(crate::popup::PopupRequest), + PopupClosed(i32), + /// CEF handed us the teardown of a webview's browser, keyed by the webview's + /// process-unique id. See `TauriCefChildLifeSpanHandler::do_close`. + #[cfg(any(target_os = "macos", windows))] + DestroyWebviewHostWindow(u32), Opened(Vec), #[cfg(target_os = "macos")] Reopen { @@ -309,6 +477,10 @@ pub(crate) enum Message { webview_id: u32, message: WebviewMessage, }, + NavigateFirstWebview { + window_id: WindowId, + url: String, + }, DragDropScriptEvent { window_id: WindowId, webview_id: u32, @@ -332,9 +504,9 @@ fn device_event_filter_to_winit(filter: DeviceEventFilter) -> winit::event_loop: pub(crate) enum EventLoopMessage { SetTheme(Option), SetDeviceEventFilter(DeviceEventFilter), - PrimaryMonitor(Sender>), - MonitorFromPoint(Sender>, f64, f64), - AvailableMonitors(Sender>), + PrimaryMonitor(Sender>>), + MonitorFromPoint(Sender>>, f64, f64), + AvailableMonitors(Sender>>), CursorPosition(Sender>>), DisplayHandle(Sender>), #[cfg(target_os = "macos")] @@ -404,6 +576,8 @@ pub(crate) struct AppState { pub(crate) winid_id_to_window_id_map: HashMap, pub(crate) callback: Box)>, pub(crate) live_browsers: usize, + live_popups: HashMap>, + pending_popups: Vec<(crate::popup::PopupRequest, Arc)>, pub(crate) exiting: bool, } @@ -412,46 +586,14 @@ pub(crate) struct WinitCefApp { receiver: Receiver>, pub(crate) state: AppState, pub(crate) scheme_registry: request_handler::SchemeRegistry, - /// Exit code from `RequestExit`, read back by `Runtime::run_return` after - /// the event loop finishes (winit's `run_app` return carries no code). - exit_code: Arc, - #[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" - ))] - last_focus_probe: Option, } -/// Stands in for the scheduling callbacks `external_message_pump` would provide. -#[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" -))] -const CEF_WORK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(4); - -/// Probing costs blocking X round-trips and focus is not that time-sensitive. -#[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" -))] -const FOCUS_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150); - impl WinitCefApp { fn new( context: RuntimeContext, receiver: Receiver>, callback: Box)>, scheme_registry: request_handler::SchemeRegistry, - exit_code: Arc, ) -> Self { Self { context, @@ -461,18 +603,11 @@ impl WinitCefApp { winid_id_to_window_id_map: HashMap::new(), callback, live_browsers: 0, + live_popups: HashMap::new(), + pending_popups: Vec::new(), exiting: false, }, scheme_registry, - exit_code, - #[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" - ))] - last_focus_probe: None, } } @@ -484,18 +619,18 @@ impl WinitCefApp { &mut self, event_loop: &dyn ActiveEventLoop, ) -> MainThreadDispatchGuard { - unsafe fn handle( - app: *mut (), - event_loop: &dyn ActiveEventLoop, - message: Message, - ) { - unsafe { &mut *app.cast::>() }.handle_message(event_loop, message); - } + let mut dispatch = Box::new(MainThreadDispatch { + app: self as *mut _, + event_loop: event_loop as *const _, + }); - let app = (self as *mut Self).cast(); - self - .context - .install_current_dispatch(app, handle::, event_loop) + let previous = self.context.current_dispatch.install(dispatch.as_mut()); + + MainThreadDispatchGuard { + context: self.context.clone(), + dispatch, + previous, + } } fn drain_messages(&mut self, event_loop: &dyn ActiveEventLoop) { @@ -507,30 +642,87 @@ impl WinitCefApp { fn handle_message(&mut self, event_loop: &dyn ActiveEventLoop, message: Message) { match message { Message::EventLoop(message) => self.handle_event_loop_message(event_loop, message), + Message::PopupPending(request, family) => { + self.state.pending_popups.push((request, family)); + } + Message::PopupCreated(request, id, family) => { + self + .state + .pending_popups + .retain(|(pending, _)| !pending.is_same(&request)); + self.state.live_popups.insert(id, family); + } + Message::PopupAborted(request) => { + self + .state + .pending_popups + .retain(|(pending, _)| !pending.is_same(&request)); + self.exit_if_done(event_loop); + } + Message::PopupClosed(id) => { + self.state.live_popups.remove(&id); + self.exit_if_done(event_loop); + } Message::BrowserClosed(_window_id, webview_id) => { - // Standalone webview.close() keeps the child in state until this - // callback, so cleanup happens here. Window/app teardown removes child - // bookkeeping before asking CEF to close; then this message is only the - // lifecycle acknowledgement that lets live_browsers drain. + // Standalone webview.close() and app shutdown keep the child in state + // until this callback, so cleanup happens here. Individual window + // teardown removes child bookkeeping first; then this message is only + // the lifecycle acknowledgement that lets live_browsers drain. // // The window_id baked into the browser's handlers can be stale after a // reparent, so locate the webview by its process-unique id across every // window rather than trusting the message's window_id — otherwise a // reparented webview's scheme-handler entries would leak and its // AppWebview would linger in the target window forever. - let child = self.state.windows.values_mut().find_map(|appwindow| { + let closed = self.state.windows.iter_mut().find_map(|(id, appwindow)| { appwindow .children .iter() .position(|child| child.webview_id == webview_id) - .map(|index| appwindow.children.remove(index)) + .map(|index| { + let child = appwindow.children.remove(index); + (*id, child, appwindow.children.is_empty()) + }) }); - if let Some(child) = child { + + let mut emptied_window = None; + if let Some((window_id, child, was_last)) = closed { self.remove_scheme_handler_entries(&child); + if was_last { + emptied_window = Some(window_id); + } } self.state.live_browsers = self.state.live_browsers.saturating_sub(1); - self.exit_if_done(event_loop); + + // A window that just lost its last webview has nothing left to show, so + // it follows the webview out through the regular close path — listeners + // still get `CloseRequested` and can keep the empty window around. + // `close_window` runs the exit check itself. + if let Some(window_id) = emptied_window { + self.request_window_close(window_id, event_loop); + } else { + self.exit_if_done(event_loop); + } + } + #[cfg(any(target_os = "macos", windows))] + Message::DestroyWebviewHostWindow(webview_id) => { + // Destroying the browser's own child view/window is what completes the + // close CEF handed over in `do_close`; CEF acknowledges it with + // `BrowserClosed`, which is where the bookkeeping is dropped. Same + // reasoning as there for searching every window by webview id. + // + // A webview that is already gone from state means its window is being + // torn down, and that teardown destroys the child view anyway. + if let Some(child) = self + .state + .windows + .values() + .flat_map(|appwindow| appwindow.children.iter()) + .find(|child| child.webview_id == webview_id) + { + child.destroy_host_window(); + } } Message::CreateWindow { window_id, @@ -564,6 +756,9 @@ impl WinitCefApp { webview_id, message, } => self.handle_webview_message(window_id, webview_id, message), + Message::NavigateFirstWebview { window_id, url } => { + self.navigate_first_webview(window_id, &url) + } Message::DragDropScriptEvent { window_id, webview_id, @@ -578,21 +773,11 @@ impl WinitCefApp { Message::Task(task) => task(), Message::RequestExit(code) => { if self.request_exit(Some(code)) { - self.exit_code.store(code, Ordering::Release); self.close_all_browsers(); self.exit_if_done(event_loop); } } - // Published tauri-runtime only has RunEvent::Opened on macOS/iOS/ - // Android; elsewhere the deep-link relaunch event has nowhere to go. - #[cfg(target_os = "macos")] Message::Opened(urls) => self.run_callback(RunEvent::Opened { urls }), - #[cfg(not(target_os = "macos"))] - Message::Opened(urls) => { - log::warn!( - "dropping deep-link open event {urls:?}: no RunEvent::Opened on this platform in published tauri-runtime" - ); - } #[cfg(target_os = "macos")] Message::Reopen { has_visible_windows, @@ -621,19 +806,19 @@ impl WinitCefApp { let monitor = event_loop .primary_monitor() .map(|monitor| winit_monitor_to_tauri_monitor(&monitor)); - let _ = tx.send(monitor); + let _ = tx.send(Ok(monitor)); } EventLoopMessage::MonitorFromPoint(tx, x, y) => { let monitor = find_monitor_from_point(event_loop.available_monitors(), x, y) .map(|monitor| winit_monitor_to_tauri_monitor(&monitor)); - let _ = tx.send(monitor); + let _ = tx.send(Ok(monitor)); } EventLoopMessage::AvailableMonitors(tx) => { let monitors = event_loop .available_monitors() .map(|monitor| winit_monitor_to_tauri_monitor(&monitor)) .collect(); - let _ = tx.send(monitors); + let _ = tx.send(Ok(monitors)); } EventLoopMessage::SetDeviceEventFilter(filter) => { event_loop.listen_device_events(device_event_filter_to_winit(filter)); @@ -685,65 +870,6 @@ impl WinitCefApp { } } - /// The browser child window owns the X11 input focus while the app is focused, - /// which X11 reports to the top-level as `FocusOut`/`NotifyInferior`. winit - /// does not filter that detail, so its focus state alone is not usable. - fn sync_window_focus(&mut self, window_id: WindowId) { - let Some(appwindow) = self.state.windows.get_mut(&window_id) else { - return; - }; - - let focused = appwindow.window.has_focus() || appwindow.owns_input_focus(); - if focused == appwindow.reported_focus { - return; - } - appwindow.reported_focus = focused; - - for child in &appwindow.children { - child.host.set_focus(i32::from(focused)); - } - if focused { - if let Some(child) = appwindow.children.first() { - child.take_input_focus(); - } - } - - self.emit_window_event(window_id, WindowEvent::Focused(focused)); - } - - /// winit already considers the top-level unfocused once the browser child holds - /// the focus, so it drops the `FocusOut` for a real loss. The loop still wakes. - fn sync_delegated_focus(&mut self) { - #[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" - ))] - { - let now = std::time::Instant::now(); - if let Some(last) = self.last_focus_probe - && now.duration_since(last) < FOCUS_PROBE_INTERVAL - { - return; - } - self.last_focus_probe = Some(now); - } - - let delegated = self - .state - .windows - .iter() - .filter(|(_, appwindow)| appwindow.reported_focus && !appwindow.window.has_focus()) - .map(|(window_id, _)| *window_id) - .collect::>(); - - for window_id in delegated { - self.sync_window_focus(window_id); - } - } - fn emit_window_event(&mut self, window_id: WindowId, event: WindowEvent) { let Some(appwindow) = self.state.windows.get(&window_id) else { return; @@ -809,18 +935,6 @@ impl WinitCefApp { } pub(crate) fn close_window(&mut self, window_id: WindowId, event_loop: &dyn ActiveEventLoop) { - if !self.state.windows.contains_key(&window_id) { - return; - } - // Emit Destroyed while the window is still in state (emit_window_event - // needs it): tauri's core prunes its window registry on this event, and - // app close hooks rely on it. Without it every closed window lives on as - // a zombie label — get_webview_window keeps returning a dead handle. The - // winit Destroyed event can't cover this: by the time it fires the id - // mapping below is already gone, so it never routes back to this window. - if !self.state.exiting { - self.emit_window_event(window_id, WindowEvent::Destroyed); - } let Some(appwindow) = self.state.windows.remove(&window_id) else { return; }; @@ -833,6 +947,7 @@ impl WinitCefApp { // shutdown drain is still enforced by live_browsers. for child in &appwindow.children { self.remove_scheme_handler_entries(child); + child.popup_family.close_all(); child.host.close_browser(1); } self.exit_if_done(event_loop); @@ -875,18 +990,32 @@ impl WinitCefApp { } } + fn navigate_first_webview(&self, window_id: WindowId, url: &str) { + let Some(frame) = self + .state + .windows + .get(&window_id) + .and_then(|window| window.children.first()) + .and_then(|webview| webview.browser.main_frame()) + else { + return; + }; + + frame.load_url(Some(&CefString::from(url))); + } + fn close_all_browsers(&mut self) { - // App shutdown follows the same eager bookkeeping cleanup as window - // teardown. live_browsers keeps the loop alive until CEF confirms every - // browser close through BrowserClosed. + // Keep each child reachable until CEF acknowledges its close. On macOS and + // Windows, do_close queues DestroyWebviewHostWindow, which needs this state + // to destroy the native child view and trigger on_before_close. Dropping + // the windows here can strand live_browsers and prevent process exit. for appwindow in self.state.windows.values() { for child in &appwindow.children { - self.remove_scheme_handler_entries(child); + child.popup_family.close_all(); + child.host.close_dev_tools(); child.host.close_browser(1); } } - self.state.windows.clear(); - self.state.winid_id_to_window_id_map.clear(); } #[cfg(target_os = "macos")] @@ -904,7 +1033,23 @@ impl WinitCefApp { } fn exit_if_done(&mut self, event_loop: &dyn ActiveEventLoop) { - if self.state.live_browsers != 0 { + // A reservation is normally resolved by `PopupCreated` or `PopupAborted`, + // but CEF discards popups without always reporting the abort — the opener + // can be torn down first, or the abort can arrive for a browser its opener + // no longer matches. Teardown (window close, app shutdown, the root's own + // native close) revokes the family, and a revoked family never admits a + // popup again, so its reservations are dead and must not hold the process + // open. Reservations of live families still gate the exit until CEF + // resolves them. + self + .state + .pending_popups + .retain(|(_, family)| !family.is_revoked()); + + if self.state.live_browsers != 0 + || !self.state.live_popups.is_empty() + || !self.state.pending_popups.is_empty() + { return; } @@ -914,10 +1059,10 @@ impl WinitCefApp { } } - /// Without `external_message_pump` nothing tells us when Chromium has work, so - /// poll it. The GLib iteration only covers GTK work CEF schedules itself: - /// `MessagePumpGlib`'s sources return early unless the pump is inside `Run()`, - /// which only `do_message_loop_work` enters. + /// Service the default GLib main context so the external message pump's GLib + /// source (and any GTK work CEF schedules) gets dispatched, then arm winit to + /// wake when the next GLib pump deadline is due. Windows/macOS need no + /// equivalent: their pump timers live on the native loop winit already runs. #[cfg(any( target_os = "linux", target_os = "dragonfly", @@ -930,11 +1075,9 @@ impl WinitCefApp { while context.pending() { context.iteration(false); } - - cef::do_message_loop_work(); - event_loop.set_control_flow(winit::event_loop::ControlFlow::WaitUntil( - std::time::Instant::now() + CEF_WORK_INTERVAL, - )); + if let Some(deadline) = self.context.cef_pump.next_deadline() { + event_loop.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(deadline)); + } } } @@ -964,6 +1107,7 @@ impl ApplicationHandler for WinitCefApp { fn about_to_wait(&mut self, event_loop: &dyn ActiveEventLoop) { let _guard = self.install_current_dispatch(event_loop); + self.apply_pending_activations(); // TODO: remove once migrated to winit-gtk4 #[cfg(any( target_os = "linux", @@ -973,7 +1117,6 @@ impl ApplicationHandler for WinitCefApp { target_os = "openbsd" ))] self.service_glib(event_loop); - self.sync_delegated_focus(); self.run_callback(RunEvent::MainEventsCleared); } @@ -995,8 +1138,9 @@ impl ApplicationHandler for WinitCefApp { WinitWindowEvent::CloseRequested => self.request_window_close(window_id, event_loop), WinitWindowEvent::Destroyed => { - // close_window emits WindowEvent::Destroyed (exactly once — a window - // that already went through close_window no longer routes here). + if !self.state.exiting { + self.emit_window_event(window_id, WindowEvent::Destroyed); + } self.close_window(window_id, event_loop); } WinitWindowEvent::SurfaceResized(size) => { @@ -1025,11 +1169,18 @@ impl ApplicationHandler for WinitCefApp { WindowEvent::Moved(PhysicalPosition::new(pos.x, pos.y)), ); } - WinitWindowEvent::Focused(_) => self.sync_window_focus(window_id), + WinitWindowEvent::Focused(focused) => { + self.emit_window_event(window_id, WindowEvent::Focused(focused)); + } WinitWindowEvent::ThemeChanged(theme) => { let system_theme = winit_theme_to_tauri_theme(theme); if let Some(explicit_theme) = appwindow.preferred_theme() { appwindow.set_theme(Some(explicit_theme)); + } else { + // Following the system: the appearance changed without going through + // `set_theme`, so the titlebar rebuild still has to be undone. + #[cfg(target_os = "macos")] + appwindow.reapply_traffic_light_position_after_appearance_change(); } self.emit_window_event(window_id, WindowEvent::ThemeChanged(system_theme)); } @@ -1057,27 +1208,9 @@ impl ApplicationHandler for WinitCefApp { } } -/// Registers the config-listed tauri custom protocol schemes with Chromium. -/// -/// Published tauri serves custom protocols at their native URL forms on -/// Linux/macOS (`tauri://localhost`, `ipc://localhost`, `asset://localhost`), -/// so Chromium must know each scheme as standard (URLs get an origin and -/// relative resolution), secure (secure-context APIs like WebCodecs and -/// getUserMedia work), CORS-enabled and fetch-enabled (the IPC transport is a -/// `fetch` POST to `ipc://localhost/`). Runs in every CEF process — the -/// helper re-exec path registers the same set via `TauriCefHelperApp`. -fn register_tauri_schemes(registrar: Option<&mut SchemeRegistrar>) { - let Some(registrar) = registrar else { return }; - let options = sys::cef_scheme_options_t::CEF_SCHEME_OPTION_STANDARD as i32 - | sys::cef_scheme_options_t::CEF_SCHEME_OPTION_SECURE as i32 - | sys::cef_scheme_options_t::CEF_SCHEME_OPTION_CORS_ENABLED as i32 - | sys::cef_scheme_options_t::CEF_SCHEME_OPTION_FETCH_ENABLED as i32; - for scheme in &crate::config::config().custom_schemes { - registrar.add_custom_scheme(Some(&CefString::from(scheme.as_str())), options); - } -} +wrap_with_args! { + wrap_app => TauriCefAppArgs; -wrap_app! { struct TauriCefApp { context: RuntimeContext, context_initialized: Arc, @@ -1086,10 +1219,6 @@ wrap_app! { } impl App { - fn on_register_custom_schemes(&self, registrar: Option<&mut SchemeRegistrar>) { - register_tauri_schemes(registrar); - } - fn render_process_handler(&self) -> Option { Some(ipc::TauriRenderProcessHandler::new()) } @@ -1125,63 +1254,6 @@ wrap_app! { } } -/// Returns the pid of a verifiably-alive process holding this cache's -/// Chromium `SingletonLock`, if any. The lock is a symlink to -/// `-`; a stale lock (dead pid, or another host on a shared -/// home) is ignored — Chromium recovers those itself. -fn live_singleton_lock_holder(cache_path: &std::path::Path) -> Option { - let target = std::fs::read_link(cache_path.join("SingletonLock")).ok()?; - let target = target.to_string_lossy(); - let (host, pid) = target.rsplit_once('-')?; - let pid: u32 = pid.parse().ok()?; - let our_host = std::fs::read_to_string("/proc/sys/kernel/hostname") - .map(|h| h.trim().to_string()) - .unwrap_or_default(); - if !our_host.is_empty() && host != our_host { - return None; - } - #[cfg(target_os = "linux")] - let held = is_other_instance(pid); - #[cfg(not(target_os = "linux"))] - let held = pid != std::process::id() - && std::process::Command::new("kill") - .args(["-0", &pid.to_string()]) - .status() - .map(|s| s.success()) - .unwrap_or(false); - held.then_some(pid) -} - -#[cfg(target_os = "linux")] -fn is_other_instance(pid: u32) -> bool { - let Ok(their_exe) = std::fs::read_link(format!("/proc/{pid}/exe")) else { - return false; - }; - if std::fs::read_link("/proc/self/exe").ok() != Some(their_exe) { - return false; - } - let mut current = std::process::id(); - while current > 1 { - if current == pid { - return false; - } - match parent_pid(current) { - Some(parent) => current = parent, - None => break, - } - } - true -} - -#[cfg(target_os = "linux")] -fn parent_pid(pid: u32) -> Option { - std::fs::read_to_string(format!("/proc/{pid}/status")) - .ok()? - .lines() - .find_map(|line| line.strip_prefix("PPid:")) - .and_then(|value| value.trim().parse().ok()) -} - pub fn run_cef_helper_process() { let args = cef::args::Args::new(); @@ -1212,10 +1284,6 @@ wrap_app! { struct TauriCefHelperApp; impl App { - fn on_register_custom_schemes(&self, registrar: Option<&mut SchemeRegistrar>) { - register_tauri_schemes(registrar); - } - fn render_process_handler(&self) -> Option { Some(ipc::TauriRenderProcessHandler::new()) } @@ -1255,6 +1323,20 @@ impl RuntimeHandle for CefRuntimeHandle { self.context.send_message(Message::RequestExit(code)) } + /// Returns the URL for a custom scheme. + /// + /// CEF always uses `http://.localhost` or `https://.localhost`. + fn custom_scheme_url(&self, scheme: &str, https: bool) -> String { + format!( + "{}://{scheme}.localhost", + if https { "https" } else { "http" } + ) + } + + fn webview_version(&self) -> Result { + crate::webview_version() + } + fn create_window) + Send + 'static>( &self, pending: PendingWindow, @@ -1285,24 +1367,22 @@ impl RuntimeHandle for CefRuntimeHandle { Ok(unsafe { DisplayHandle::borrow_raw(raw.0) }) } - fn primary_monitor(&self) -> Option { - event_loop_getter!(self, PrimaryMonitor).ok().flatten() + fn primary_monitor(&self) -> Result> { + event_loop_getter!(self, PrimaryMonitor)? } - fn monitor_from_point(&self, x: f64, y: f64) -> Option { + fn monitor_from_point(&self, x: f64, y: f64) -> Result> { let (tx, rx) = mpsc::channel(); self .context .send_message(Message::EventLoop(EventLoopMessage::MonitorFromPoint( tx, x, y, - ))) - .and_then(|_| rx.recv().map_err(|_| Error::FailedToReceiveMessage)) - .ok() - .flatten() + )))?; + rx.recv().map_err(|_| Error::FailedToReceiveMessage)? } - fn available_monitors(&self) -> Vec { - event_loop_getter!(self, AvailableMonitors).unwrap_or_default() + fn available_monitors(&self) -> Result> { + event_loop_getter!(self, AvailableMonitors)? } fn cursor_position(&self) -> Result> { @@ -1351,7 +1431,7 @@ impl RuntimeHandle for CefRuntimeHandle { } } -pub struct CefRuntime { +pub struct CefRuntime { event_loop: EventLoop, receiver: Receiver>, context: RuntimeContext, @@ -1433,7 +1513,7 @@ impl TerminationSignals { impl CefRuntime { fn init( mut event_loop_builder: EventLoopBuilder, - #[allow(unused_variables)] runtime_args: RuntimeInitArgs, + runtime_args: RuntimeInitArgs, ) -> Result { // Snapshot before CEF can touch anything, so we can tell an embedder's own // signal policy apart from the handlers CEF installs in `cef::initialize`. @@ -1479,7 +1559,11 @@ impl CefRuntime { // The CEF API version table must be initialized before any other CEF call // (e.g. `args.as_cmd_line()` below), otherwise the process crashes with no // diagnostics. - let _ = cef::api_hash(sys::CEF_API_VERSION_LAST, 0); + let version = runtime_args + .runtime_init_attrs + .api_version + .unwrap_or(sys::CEF_API_VERSION_LAST); + let _ = cef::api_hash(version, 0); // Handle CEF subprocesses (renderer/GPU/utility) before any browser-only // setup such as building the event loop, creating cache directories, or the @@ -1502,51 +1586,24 @@ impl CefRuntime { std::process::exit(ret.max(0)); } - // Published tauri's RuntimeInitArgs has no channel for CEF-specific init - // data (identifier/switches/cache path), so it comes from the - // process-global crate config instead — see `crate::configure`. - let cef_config = crate::config::config(); - let mut command_line_args = cef_config.command_line_args.clone(); - let deep_link_schemes = cef_config.deep_link_schemes.clone(); - - // Once the GPU mode fallback list is exhausted Chromium kills the browser - // process with a `LOG(FATAL)`, seen as a bare "Illegal instruction" with no - // panic and no log. Suspend/resume GPU resets get there on their own. - // See `GpuDataManagerImplPrivate::FallBackToNextGpuMode`. - command_line_args.push(("disable-gpu-process-crash-limit".to_string(), None)); + let Cef { + mut command_line_args, + deep_link_schemes, + cache_path: cache_path_override, + settings_callback, + // Already applied, above, before the first CEF call. + api_version: _, + } = runtime_args.runtime_init_attrs; - let cache_path = cef_config.cache_path.clone().unwrap_or_else(|| { + let cache_path = cache_path_override.unwrap_or_else(|| { let cache_base = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); - cache_base.join(&cef_config.identifier).join("cef") + cache_base.join(&runtime_args.identifier).join("cef") }); let _ = create_dir_all(&cache_path); - // Chromium guards its profile with a `SingletonLock` symlink whose target - // is `-`. A second browser process on the same cache dir - // doesn't fail at initialize — Chromium only surfaces the conflict later, - // as a renderer/GPU startup failure. Fail fast with an actionable error - // instead when the holder is verifiably alive. - if let Some(holder_pid) = live_singleton_lock_holder(&cache_path) { - return Err(Error::CreateWebview( - format!( - "CEF cache {} is held by running process {holder_pid} (SingletonLock); \ - close that instance or configure a distinct cache_path/identifier", - cache_path.display() - ) - .into(), - )); - } - - #[cfg(target_os = "linux")] - if crate::config::native_wayland() { - command_line_args.push(("ozone-platform".into(), Some("wayland".into()))); - event_loop_builder.with_wayland(); - } else { - command_line_args.push(("ozone-platform".into(), Some("x11".into()))); - event_loop_builder.with_x11(); - } - + // Force X11 usage on Linux #[cfg(any( + target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", @@ -1587,12 +1644,13 @@ impl CefRuntime { cache_path: Arc::new(cache_path.clone()), }; - let mut app = TauriCefApp::new( - context.clone(), - context_initialized.clone(), + command_line_args.push(("--no-first-run".to_string(), None)); + let mut app = TauriCefApp::build(TauriCefAppArgs { + context: context.clone(), + context_initialized: context_initialized.clone(), deep_link_schemes, command_line_args, - ); + }); // Subprocesses already exited above, so this must be the browser process; // `execute_process` returns -1 there to signal normal startup should follow. @@ -1606,35 +1664,15 @@ impl CefRuntime { "CEF browser process unexpectedly returned from execute_process" ); - // CEF's `MessagePumpExternal::Run` is a 10ms time slice with a no-op `Quit`, - // so nested run loops end immediately. HTML5 drag and native context menus - // both need one that lasts. Chromium's `MessagePumpGlib` is a real loop. - #[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" - ))] - const EXTERNAL_MESSAGE_PUMP: i32 = 0; - #[cfg(not(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" - )))] - const EXTERNAL_MESSAGE_PUMP: i32 = 1; - - let settings = cef::Settings { + let mut settings = cef::Settings { no_sandbox: !cfg!(feature = "sandbox") as i32, cache_path: cache_path.to_string_lossy().to_string().as_str().into(), - external_message_pump: EXTERNAL_MESSAGE_PUMP, - // Comma-delimited; empty keeps CEF's http/https-only default. The - // defaults stay included because exclude_defaults is left 0. - cookieable_schemes_list: cef_config.cookieable_schemes.join(",").as_str().into(), + external_message_pump: 1, ..Default::default() }; + if let Some(callback) = settings_callback { + callback(&mut settings); + } if cef::initialize( Some(args.as_main_args()), Some(&settings), @@ -1654,21 +1692,6 @@ impl CefRuntime { ))] pre_cef_signals.restore(); - // Baseline for embedders that never touch GTK. One that calls `gtk_init` - // must call `install_x_error_handlers` again afterwards — GTK's X11 backend - // replaces the handler during init. - #[cfg(target_os = "linux")] - if !crate::config::native_wayland() { - crate::platform::linux::install_x_error_handlers(); - } - #[cfg(any( - target_os = "dragonfly", - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd" - ))] - crate::platform::linux::install_x_error_handlers(); - #[cfg(target_os = "macos")] let app_delegate = if !is_helper { use crate::platform::macos::AppDelegateEvent; @@ -1720,8 +1743,12 @@ impl Runtime for CefRuntime { type WebviewDispatcher = CefWebviewDispatcher; type Handle = CefRuntimeHandle; type EventLoopProxy = EventProxy; + type RuntimeWebviewAttributes = CefWebviewAttributes; + type Webview = Webview; + type RuntimeInitAttrs = Cef; + type WindowOpener = NewWindowOpener; - fn new(args: RuntimeInitArgs) -> Result { + fn new(args: RuntimeInitArgs) -> Result { Self::init(EventLoopBuilder::default(), args) } @@ -1733,15 +1760,8 @@ impl Runtime for CefRuntime { target_os = "netbsd", target_os = "openbsd" ))] - fn new_any_thread(args: RuntimeInitArgs) -> Result { + fn new_any_thread(args: RuntimeInitArgs) -> Result { let mut event_loop_builder = EventLoopBuilder::default(); - #[cfg(target_os = "linux")] - if crate::config::native_wayland() { - EventLoopBuilderExtWayland::with_any_thread(&mut event_loop_builder, true); - } else { - EventLoopBuilderExtX11::with_any_thread(&mut event_loop_builder, true); - } - #[cfg(not(target_os = "linux"))] event_loop_builder.with_any_thread(true); Self::init(event_loop_builder, args) } @@ -1775,7 +1795,10 @@ impl Runtime for CefRuntime { } fn primary_monitor(&self) -> Option { - event_loop_getter!(self, PrimaryMonitor).ok().flatten() + event_loop_getter!(self, PrimaryMonitor) + .flatten() + .ok() + .unwrap_or_default() } fn monitor_from_point(&self, x: f64, y: f64) -> Option { @@ -1786,12 +1809,16 @@ impl Runtime for CefRuntime { tx, x, y, ))) .and_then(|_| rx.recv().map_err(|_| Error::FailedToReceiveMessage)) + .ok()? .ok() - .flatten() + .unwrap_or_default() } fn available_monitors(&self) -> Vec { - event_loop_getter!(self, AvailableMonitors).unwrap_or_default() + event_loop_getter!(self, AvailableMonitors) + .flatten() + .ok() + .unwrap_or_default() } fn cursor_position(&self) -> Result> { @@ -1844,106 +1871,19 @@ impl Runtime for CefRuntime { } fn run_return) + 'static>(self, callback: F) -> i32 { - let exit_code = Arc::new(std::sync::atomic::AtomicI32::new(0)); - #[cfg(target_os = "linux")] - if crate::config::native_wayland() { - let app = crate::wayland::App::new( - self.context, - self.receiver, - Box::new(callback), - self.scheme_registry, - exit_code.clone(), - ); - let _ = self.event_loop.run_app(app); - cef::shutdown(); - return exit_code.load(Ordering::Acquire); - } + self.run(callback); + // TODO: return the exit code from the runtime, if possible. For now, always return 0 + 0 + } + + fn run) + 'static>(self, callback: F) { let app = WinitCefApp::new( self.context, self.receiver, Box::new(callback), self.scheme_registry, - exit_code.clone(), ); let _ = self.event_loop.run_app(app); cef::shutdown(); - exit_code.load(Ordering::Acquire) - } - - fn run) + 'static>(self, callback: F) { - self.run_return(callback); - } -} - -#[cfg(all(test, target_os = "linux"))] -mod tests { - use super::live_singleton_lock_holder; - use std::process::{Child, Command, Stdio}; - - fn lock_dir(target: &str) -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - std::os::unix::fs::symlink(target, dir.path().join("SingletonLock")).unwrap(); - dir - } - - fn hostname() -> String { - std::fs::read_to_string("/proc/sys/kernel/hostname") - .unwrap() - .trim() - .to_string() - } - - fn spawn_sibling() -> Child { - let child = Command::new(std::env::current_exe().unwrap()) - .arg("sleeper_child") - .env("CEF_SINGLETON_LOCK_SLEEPER", "1") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - for _ in 0..200 { - if std::fs::read_link(format!("/proc/{}/exe", child.id())).is_ok() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - child - } - - #[test] - fn sleeper_child() { - if std::env::var_os("CEF_SINGLETON_LOCK_SLEEPER").is_some() { - std::thread::sleep(std::time::Duration::from_secs(10)); - } - } - - #[test] - fn lock_from_a_foreign_pid_namespace_is_ignored() { - let dir = lock_dir(&format!("{}-2", hostname())); - assert_eq!(live_singleton_lock_holder(dir.path()), None); - } - - #[test] - fn our_own_pid_is_not_a_holder() { - let dir = lock_dir(&format!("{}-{}", hostname(), std::process::id())); - assert_eq!(live_singleton_lock_holder(dir.path()), None); - } - - #[test] - fn a_lock_from_another_host_is_ignored() { - let dir = lock_dir(&format!("not-this-host-{}", std::process::id())); - assert_eq!(live_singleton_lock_holder(dir.path()), None); - } - - #[test] - fn a_live_second_instance_is_reported() { - let mut sibling = spawn_sibling(); - let dir = lock_dir(&format!("{}-{}", hostname(), sibling.id())); - - let holder = live_singleton_lock_holder(dir.path()); - - let _ = sibling.kill(); - let _ = sibling.wait(); - assert_eq!(holder, Some(sibling.id())); } } diff --git a/src/tauri_ext.rs b/src/tauri_ext.rs new file mode 100644 index 0000000..1fd645c --- /dev/null +++ b/src/tauri_ext.rs @@ -0,0 +1,348 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Extension traits exposing CEF-specific APIs on [`tauri`] types. +//! +//! The traits are implemented for the statically typed [`CefRuntime`](crate::CefRuntime) +//! and for the type-erased [`tauri::DynRuntime`]. With the latter, the methods fail with +//! [`tauri_runtime::Error::RuntimeTypeMismatch`] when the application is not running on CEF. + +use std::sync::Arc; + +use tauri::{EventLoopMessage, Manager, Runtime, Webview, WebviewWindow}; +use tauri_runtime::dynamic::{DynWebviewAttributes, DynWebviewDispatcher, DynWindowOpener}; + +use crate::{ + CefWebviewAttributes, CefWebviewDispatcher, DevToolsProtocol, FrameEvent, NewWindowOpener, + RuntimeStyle, +}; + +type Result = std::result::Result; + +fn not_cef() -> tauri::Error { + tauri_runtime::Error::RuntimeTypeMismatch( + "the application is not running on the CEF runtime".into(), + ) + .into() +} + +/// Webview dispatchers that may expose the underlying [`CefWebviewDispatcher`]. +pub trait AsCefWebviewDispatcher { + /// Returns the CEF webview dispatcher, if the runtime is CEF. + fn as_cef_webview_dispatcher(&self) -> Option<&CefWebviewDispatcher>; +} + +impl AsCefWebviewDispatcher for CefWebviewDispatcher { + fn as_cef_webview_dispatcher(&self) -> Option<&CefWebviewDispatcher> { + Some(self) + } +} + +impl AsCefWebviewDispatcher for DynWebviewDispatcher { + fn as_cef_webview_dispatcher(&self) -> Option<&CefWebviewDispatcher> { + self.downcast_ref() + } +} + +/// Window openers that may expose the CEF [`NewWindowOpener`]. +/// +/// Lets a new window handler read the CEF popup source regardless of the runtime generic in use: +/// +/// ```rust,no_run +/// use tauri::{WebviewUrl, WebviewWindowBuilder, webview::NewWindowResponse}; +/// use tauri_runtime_cef::AsCefWindowOpener; +/// +/// tauri::Builder::default() +/// .runtime(tauri_runtime_cef::Cef::default()) +/// .setup(|app| { +/// WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) +/// .on_new_window(|url, features| { +/// if let Some(opener) = features.opener().as_cef_window_opener() { +/// println!("{url} was opened by {:?}", opener.source_url()); +/// } +/// NewWindowResponse::Allow +/// }) +/// .build()?; +/// Ok(()) +/// }); +/// ``` +pub trait AsCefWindowOpener { + /// Returns the CEF window opener, `None` when the opener belongs to another runtime. + fn as_cef_window_opener(&self) -> Option<&NewWindowOpener>; +} + +impl AsCefWindowOpener for NewWindowOpener { + fn as_cef_window_opener(&self) -> Option<&NewWindowOpener> { + Some(self) + } +} + +impl AsCefWindowOpener for DynWindowOpener { + fn as_cef_window_opener(&self) -> Option<&NewWindowOpener> { + self.downcast_ref() + } +} + +/// Runtime webview attributes that may expose the [`CefWebviewAttributes`]. +pub trait AsCefWebviewAttributes { + /// Returns the CEF attributes, `None` when the attributes belong to another runtime. + fn as_cef_webview_attributes_mut(&mut self) -> Option<&mut CefWebviewAttributes>; +} + +impl AsCefWebviewAttributes for CefWebviewAttributes { + fn as_cef_webview_attributes_mut(&mut self) -> Option<&mut CefWebviewAttributes> { + Some(self) + } +} + +impl AsCefWebviewAttributes for DynWebviewAttributes { + fn as_cef_webview_attributes_mut(&mut self) -> Option<&mut CefWebviewAttributes> { + self.get_or_default() + } +} + +/// Modifies the CEF attributes of a webview builder, if the builder's attributes are not of another runtime. +fn with_cef_webview_attributes( + attributes: &mut A, + f: impl FnOnce(&mut CefWebviewAttributes), +) { + match attributes.as_cef_webview_attributes_mut() { + Some(attributes) => f(attributes), + None => log::warn!( + "ignoring the CEF webview attributes: attributes of another runtime were already set on the webview builder" + ), + } +} + +/// CEF-specific APIs of [`tauri::Webview`] and [`tauri::WebviewWindow`]. +pub trait WebviewCefExt { + /// Send a message to the DevTools agent. The message should be a UTF-8 encoded JSON + /// string following the Chrome DevTools Protocol format. + /// + /// Callers share one native request identifier space on this browser, so the + /// message's `id` must come from + /// [`allocate_devtools_message_id`](crate::allocate_devtools_message_id). + /// A hardcoded or self-incremented `id` can collide with a request another + /// caller already sent, which consumes that producer's + /// [`DevToolsProtocol::MethodResult`]. The runtime's own requests are issued + /// from a reserved range the public allocator never returns, so they cannot + /// be consumed this way. + /// + /// # Examples + /// + /// ```rust,no_run + /// use tauri::Manager; + /// use tauri_runtime_cef::{WebviewCefExt, allocate_devtools_message_id}; + /// + /// tauri::Builder::default() + /// .runtime(tauri_runtime_cef::Cef::default()) + /// .setup(|app| { + /// let webview = app.get_webview_window("main").unwrap(); + /// // Enable Page domain to receive page lifecycle events + /// let message_id = allocate_devtools_message_id()?; + /// let msg = format!(r#"{{"id":{message_id},"method":"Page.enable","params":{{}}}}"#); + /// webview.send_dev_tools_message(msg.as_bytes())?; + /// Ok(()) + /// }); + /// ``` + fn send_dev_tools_message(&self, message: &[u8]) -> Result<()>; + + /// Register a callback to receive DevTools protocol messages. Messages include + /// both method results and events from the DevTools agent. + /// + /// The callback observes the whole browser, including requests the runtime and + /// other callers sent. Match [`DevToolsProtocol::MethodResult`] against an + /// identifier obtained from + /// [`allocate_devtools_message_id`](crate::allocate_devtools_message_id) + /// instead of assuming every result belongs to this observer. + /// + /// It is scoped to this webview's own native browser, so a CEF-owned popup is + /// a separate browser whose protocol traffic — its page content, its network + /// activity and its dialog messages — is never reported here; observe popups + /// through [`Webview::popups`](crate::Webview::popups). + /// + /// # Examples + /// + /// ```rust,no_run + /// use tauri::Manager; + /// use tauri_runtime_cef::{DevToolsProtocol, WebviewCefExt, allocate_devtools_message_id}; + /// + /// tauri::Builder::default() + /// .runtime(tauri_runtime_cef::Cef::default()) + /// .setup(|app| { + /// let webview = app.get_webview_window("main").unwrap(); + /// let message_id = allocate_devtools_message_id()?; + /// webview.on_dev_tools_protocol(move |protocol| { + /// match protocol { + /// DevToolsProtocol::Message(msg) => { + /// if let Ok(s) = std::str::from_utf8(&msg) { + /// println!("DevTools message: {}", s); + /// } + /// } + /// DevToolsProtocol::Event { method, params } => { + /// println!("DevTools event: {} {:?}", method, params); + /// } + /// // Only this result answers the request sent below. + /// DevToolsProtocol::MethodResult { message_id: id, success, .. } if id == message_id => { + /// println!("Page.enable success={}", success); + /// } + /// DevToolsProtocol::MethodResult { .. } => {} + /// } + /// })?; + /// let msg = format!(r#"{{"id":{message_id},"method":"Page.enable","params":{{}}}}"#); + /// webview.send_dev_tools_message(msg.as_bytes())?; + /// Ok(()) + /// }); + /// ``` + fn on_dev_tools_protocol( + &self, + f: F, + ) -> Result<()>; + + /// Executes a closure with the CEF platform webview handle, on the CEF UI thread. + /// + /// See [`crate::Webview`] for the native state it exposes, which is sampled + /// immediately before the closure runs and is not refreshed afterwards. + fn with_cef_webview(&self, f: F) -> Result<()>; +} + +impl WebviewCefExt for Webview +where + R::WebviewDispatcher: AsCefWebviewDispatcher, +{ + fn send_dev_tools_message(&self, message: &[u8]) -> Result<()> { + self + .dispatcher() + .as_cef_webview_dispatcher() + .ok_or_else(not_cef)? + .send_dev_tools_message(message) + .map_err(Into::into) + } + + fn on_dev_tools_protocol( + &self, + f: F, + ) -> Result<()> { + self + .dispatcher() + .as_cef_webview_dispatcher() + .ok_or_else(not_cef)? + .on_dev_tools_protocol(f) + .map_err(Into::into) + } + + fn with_cef_webview(&self, f: F) -> Result<()> { + if self.dispatcher().as_cef_webview_dispatcher().is_none() { + return Err(not_cef()); + } + self.with_webview(move |webview| { + if let Some(webview) = webview.downcast_ref::() { + f(webview) + } + }) + } +} + +impl WebviewCefExt for WebviewWindow +where + R::WebviewDispatcher: AsCefWebviewDispatcher, +{ + fn send_dev_tools_message(&self, message: &[u8]) -> Result<()> { + self.as_ref().send_dev_tools_message(message) + } + + fn on_dev_tools_protocol( + &self, + f: F, + ) -> Result<()> { + self.as_ref().on_dev_tools_protocol(f) + } + + fn with_cef_webview(&self, f: F) -> Result<()> { + self.as_ref().with_cef_webview(f) + } +} + +/// CEF-specific APIs of [`tauri::WebviewWindowBuilder`]. +pub trait WebviewWindowBuilderCefExt { + /// Sets the browser runtime style. + /// + /// See [`RuntimeStyle`] for more information. + #[must_use] + fn browser_runtime_style(self, style: RuntimeStyle) -> Self; + + /// Observes native CEF lifecycle events for main and child frames. + /// + /// The callback runs synchronously on CEF's UI thread. It must return + /// promptly and must not wait for an event-loop operation. This observer + /// does not replace the navigation policy configured by `on_navigation`. + /// It is scoped to this webview's own native browser, so a CEF-owned popup + /// is a separate browser that is never reported here — observe popups + /// through [`Webview::popups`](crate::Webview::popups). + #[must_use] + fn on_frame_event(self, handler: F) -> Self; +} + +impl<'a, R: Runtime, M: Manager> WebviewWindowBuilderCefExt + for tauri::WebviewWindowBuilder<'a, R, M> +where + R::RuntimeWebviewAttributes: AsCefWebviewAttributes, +{ + fn browser_runtime_style(mut self, style: RuntimeStyle) -> Self { + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.runtime_style = Some(style); + }); + self + } + + fn on_frame_event(mut self, handler: F) -> Self { + let handler = Arc::new(handler); + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.frame_event_handler = Some(handler); + }); + self + } +} + +/// CEF-specific APIs of [`tauri::webview::WebviewBuilder`]. +#[cfg(feature = "unstable")] +pub trait WebviewBuilderCefExt { + /// Sets the browser runtime style. + /// + /// See [`RuntimeStyle`] for more information. + #[must_use] + fn browser_runtime_style(self, style: RuntimeStyle) -> Self; + + /// Observes native CEF lifecycle events for main and child frames. + /// + /// The callback runs synchronously on CEF's UI thread. It must return + /// promptly and must not wait for an event-loop operation. This observer + /// does not replace the navigation policy configured by `on_navigation`. + /// It is scoped to this webview's own native browser, so a CEF-owned popup + /// is a separate browser that is never reported here — observe popups + /// through [`Webview::popups`](crate::Webview::popups). + #[must_use] + fn on_frame_event(self, handler: F) -> Self; +} + +#[cfg(feature = "unstable")] +impl WebviewBuilderCefExt for tauri::webview::WebviewBuilder +where + R::RuntimeWebviewAttributes: AsCefWebviewAttributes, +{ + fn browser_runtime_style(mut self, style: RuntimeStyle) -> Self { + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.runtime_style = Some(style); + }); + self + } + + fn on_frame_event(mut self, handler: F) -> Self { + let handler = Arc::new(handler); + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.frame_event_handler = Some(handler); + }); + self + } +} diff --git a/src/webview.rs b/src/webview.rs index f3cd17c..168ca26 100644 --- a/src/webview.rs +++ b/src/webview.rs @@ -6,23 +6,25 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::{ Mutex, - atomic::{AtomicI32, Ordering}, + atomic::Ordering, mpsc::{self, Receiver, Sender}, }; use cef::*; use sha2::{Digest, Sha256}; use tauri_runtime::{ - Cookie, Error, Result, UserEvent, WebviewDispatch, WebviewEventId, + Cookie, Error, Result, Runtime, UserEvent, WebviewDispatch, WebviewEventId, dpi::{PhysicalPosition, PhysicalSize, Position, Rect, Size}, - webview::{DetachedWebview, InitializationScript, PendingWebview, WebviewAttributes}, + webview::{ + DetachedWebview, InitializationScript, PendingWebview, UriSchemeProtocolHandler, + WebviewAttributes, + }, window::{WebviewEvent, WindowId}, }; use tauri_utils::{Theme, config::Color, html::normalize_script_for_csp}; use url::Url; use crate::cef_impl::{client as browser_client, cookie, request_context, request_handler}; -use crate::compat::{self, UriSchemeProtocolHandler}; use crate::runtime::{CefRuntime, Message, RuntimeContext, WinitCefApp}; use crate::window::AppWindow; @@ -33,11 +35,92 @@ use crate::window::AppWindow; #[derive(Clone)] pub struct Webview { browser: cef::Browser, + snapshot: WebviewSnapshot, + frame_navigation_state: crate::FrameNavigationState, + popups: Vec, + opener: Option, +} + +/// Native state sampled on the CEF UI thread immediately before a +/// `with_webview` callback. This does not assert renderer responsiveness or +/// that the view is unobscured on screen. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct WebviewSnapshot { + /// Native browser identity, distinct for each popup. + pub browser_id: i32, + /// Native JavaScript dialog observation. Unknown is distinct from absent. + pub dialogs: crate::NativeDialogObservation, + /// All-frame document generation validated against the current native frame + /// identities and load state. `None` means document admission is unavailable. + pub document: Option, + /// Runtime window label; CEF-owned popups have no Tauri window label. + pub window_label: Option, + /// Opaque lifetime of the runtime window. Labels and native handle values + /// may be reused after teardown; this token distinguishes their replacements. + /// `None` means the runtime could not observe a native window lifetime. + pub window: Option, + /// Whether the actual native parent matches the observed native window. + /// `None` means the platform could not establish the relationship. A + /// CEF-owned popup always reports `None`, permanently rather than + /// transiently: CEF owns its native window, so there is no independently + /// observed parent for the runtime to check it against. + pub parent_matches: Option, + /// Current bounds relative to the native parent, in the indicated DPI units. + pub bounds: Option, + /// Native view visibility. `None` means native inspection was unavailable. + /// Visibility is separate from occlusion, minimization, and page lifecycle. + pub visible: Option, } impl Webview { - pub(crate) fn new(browser: cef::Browser) -> Self { - Self { browser } + pub(crate) fn new( + browser: cef::Browser, + snapshot: WebviewSnapshot, + frame_navigation_state: crate::FrameNavigationState, + ) -> Self { + Self { + browser, + snapshot, + frame_navigation_state, + popups: Vec::new(), + opener: None, + } + } + + /// Returns the native state sampled for this `with_webview` callback. + /// Retaining the handle does not refresh this observation. + pub fn snapshot(&self) -> &WebviewSnapshot { + &self.snapshot + } + + /// Returns read-only live navigation state for this native browser lifetime. + /// Unlike `snapshot`, this handle follows subsequent native frame events. + pub fn frame_navigation_state(&self) -> &crate::FrameNavigationState { + &self.frame_navigation_state + } + + pub(crate) fn set_opener(&mut self, opener: crate::FrameNavigationState) { + self.opener = Some(opener); + } + + /// Native CEF-owned popup descendants sampled in this same UI-thread callback. + /// Popup windows have no Tauri label and retain their actual CEF opener. + pub fn popups(&self) -> &[Webview] { + &self.popups + } + + /// Exact native opener lifetime, if this is a CEF-owned popup. + pub fn opener(&self) -> Option<&crate::FrameNavigationState> { + self.opener.as_ref() + } + + /// Select an observed document within this runtime-owned browser family. + /// The returned snapshot is valid only for the current native callback. + pub fn for_document(&self, document: &crate::NativeDocumentToken) -> Option<&Webview> { + std::iter::once(self) + .chain(self.popups.iter()) + .find(|view| view.snapshot.document.as_ref() == Some(document)) } /// Returns the [`cef::Browser`] backing this webview. @@ -105,13 +188,27 @@ fn browser_settings_from_webview_attributes( } } +/// A Chrome DevTools Protocol notification observed on a native browser. +/// +/// Observers see the whole browser, including requests issued by the runtime +/// itself and by other callers, so nothing here is scoped to one producer. +/// No notification names a browser, and none has to: an observer is registered +/// on one native browser and never receives another's traffic — a CEF-owned +/// popup is a separate browser, observed only by the runtime's own internal +/// observer. #[derive(Debug, Clone)] pub enum DevToolsProtocol { + /// The raw agent message, before it is classified as an event or a result. Message(Vec), - Event { - method: String, - params: Vec, - }, + /// An agent event. Events are unsolicited and carry no request identifier. + Event { method: String, params: Vec }, + /// The result of one request. + /// + /// `message_id` correlates with the `id` of the request that produced it. + /// Compare it against an identifier obtained from + /// [`allocate_devtools_message_id`](crate::allocate_devtools_message_id); + /// a result whose identifier you did not allocate answers someone else's + /// request. Numeric correlation does not authorize a browser or document. MethodResult { message_id: i32, success: bool, @@ -191,6 +288,9 @@ pub(crate) struct AppWebview { pub(crate) label: String, pub(crate) browser: cef::Browser, pub(crate) browser_id: i32, + pub(crate) frame_navigation_state: crate::FrameNavigationState, + pub(crate) popup_family: Arc, + pub(crate) dialogs: crate::dialog::DialogState, pub(crate) host: cef::BrowserHost, pub(crate) uri_scheme_protocols: Arc>>>, pub(crate) devtools_protocol_handlers: Arc>>>, @@ -302,16 +402,16 @@ impl WinitCefApp { )); }; + // On Windows a window's webviews are sibling child HWNDs. Put each new one + // on top of the ones already there — the order they were created in — and + // pin it, so Chromium's focus raise cannot reshuffle them behind our back + // and bury an overlay webview under the one that fills the window. + #[cfg(windows)] + child.raise_to_top(); + *live_browsers += 1; appwindow.children.push(child); layout_app_window(appwindow); - // No winit focus event is coming for a window that is already focused. - if appwindow.reported_focus - && let Some(child) = appwindow.children.last() - { - child.host.set_focus(1); - child.take_input_focus(); - } Ok(()) } @@ -344,45 +444,65 @@ impl WinitCefApp { let on_page_load_handler = pending.on_page_load_handler.take().map(Arc::from); let document_title_changed_handler = pending.document_title_changed_handler.take().map(Arc::from); - // Published PendingWebview has no address-changed channel (feat/cef-only); - // the client plumbing stays for when upstream ships it. - let address_changed_handler: Option> = None; let devtools_enabled = (cfg!(debug_assertions) || cfg!(feature = "devtools")) && pending.webview_attributes.devtools.unwrap_or(true); let drag_drop_handler_enabled = pending.webview_attributes.drag_drop_handler_enabled; let drag_drop_state = Arc::new(Mutex::new(browser_client::DragDropState::default())); - #[cfg(any(target_os = "macos", target_os = "ios"))] let web_content_process_terminate_handler = pending .on_web_content_process_terminate_handler .take() - .map(|handler| Arc::from(handler) as Arc); - #[cfg(not(any(target_os = "macos", target_os = "ios")))] - let web_content_process_terminate_handler: Option> = None; + .map(Arc::from); + let frame_navigation_state = crate::FrameNavigationState::new(); + let popup_family = Arc::new(crate::popup::PopupFamily::new( + frame_navigation_state.clone(), + )); + let dialogs = crate::dialog::DialogState::new(frame_navigation_state.clone()); + let frame_state_for_events = frame_navigation_state.clone(); + let frame_event_handler = pending + .runtime_specific_attributes + .frame_event_handler + .clone(); let handlers = browser_client::TauriCefBrowserClientHandlers { + // The internal navigation observer must see every notification this client + // receives; the app observer is bound to this exact native browser. CEF can + // route a browser this webview does not own through the same client — a + // DevTools window is the standing case — and a `FrameEvent` carries the full + // URL, so those must never reach an observer registered for this webview. + frame_event_handler: Some(Arc::new(move |event| { + frame_state_for_events.on_frame_event(&event); + if frame_state_for_events.has_browser_id(event.browser_id) + && let Some(handler) = &frame_event_handler + { + handler(event); + } + })), ipc_handler: pending.ipc_handler.map(Arc::from), on_page_load_handler, document_title_changed_handler, navigation_handler: pending.navigation_handler.map(Arc::from), - address_changed_handler, new_window_handler: pending.new_window_handler.map(Arc::from), download_handler: pending.download_handler.take(), web_content_process_terminate_handler, }; - let mut client = browser_client::TauriCefBrowserClient::new( - context.clone(), - window_id, - webview_id, - pending.label.clone(), - Some(pending.url.as_str().to_string()), - devtools_enabled, - drag_drop_event_target, - drag_drop_handler_enabled, - drag_drop_state, - handlers, - context.proxy.clone(), - context.sender.clone(), - ); + let mut client = + browser_client::TauriCefBrowserClient::build(browser_client::TauriCefBrowserClientArgs { + context: context.clone(), + window_id, + webview_id, + label: pending.label.clone(), + initial_url: Some(pending.url.as_str().to_string()), + devtools_enabled, + drag_drop_event_target, + drag_drop_handler_enabled, + drag_drop_state, + frame_navigation_state: frame_navigation_state.clone(), + popup_family: Arc::downgrade(&popup_family), + opener: None, + handlers, + proxy: context.proxy.clone(), + sender: context.sender.clone(), + }); // If the bounds are not specified, default to the parent window's size and position. // aka full-window webview. @@ -391,9 +511,9 @@ impl WinitCefApp { size: parent_size.into(), }); #[cfg(not(target_os = "macos"))] - let bounds = compat::rect_to_physical::(bounds, scale); + let bounds = bounds.to_physical::(scale); #[cfg(target_os = "macos")] - let bounds = compat::rect_to_logical::(bounds, scale); + let bounds = bounds.to_logical::(scale); let bounds = cef::Rect { x: bounds.position.x, y: bounds.position.y, @@ -401,13 +521,12 @@ impl WinitCefApp { height: bounds.size.height, }; - // Alloy style has no drag-and-drop implementation for windowed rendering, so HTML5 - // drags never start. Chrome style routes through Chrome's Views/Aura browser, which - // installs a drag-drop client. It cannot be parented natively on macOS (CEF #3294). - #[cfg(target_os = "macos")] - let cef_runtime_style = cef::RuntimeStyle::ALLOY; - #[cfg(not(target_os = "macos"))] - let cef_runtime_style = cef::RuntimeStyle::CHROME; + // Let CEF pick the runtime style unless overridden per-webview. + let cef_runtime_style = match pending.runtime_specific_attributes.runtime_style { + Some(RuntimeStyle::Alloy) => cef::RuntimeStyle::ALLOY, + Some(RuntimeStyle::Chrome) => cef::RuntimeStyle::CHROME, + None => cef::RuntimeStyle::DEFAULT, + }; let mut window_info = cef::WindowInfo::default().set_as_child(parent, &bounds); window_info.runtime_style = cef_runtime_style; @@ -469,12 +588,22 @@ impl WinitCefApp { } } - let devtools_protocol_handlers = Arc::new(Mutex::new(Vec::new())); + // The app observers registered through `on_dev_tools_protocol` live in + // this list, and it belongs to this one native browser. Every CEF-owned + // popup registers its own protocol observer against a list of its own, + // so nothing registered here ever observes a popup: a + // `DevToolsProtocol` notification carries the page's content, its + // network activity and its dialog messages with no browser identity to + // separate them, and a popup navigates wherever its own content goes — + // an SSO or OAuth window is the standing case. + let devtools_protocol_handlers: Arc>>> = + Arc::default(); let pending_initial_loads: PendingInitialLoads = Arc::new(Mutex::new(HashMap::new())); let devtools_observer_registration = Arc::new(Mutex::new(add_dev_tools_observer( &browser, devtools_protocol_handlers.clone(), pending_initial_loads.clone(), + dialogs.clone(), ))); load_initial_url_after_registering_initialization_scripts( &browser, @@ -491,6 +620,9 @@ impl WinitCefApp { label, browser, browser_id, + frame_navigation_state, + popup_family, + dialogs, host, uri_scheme_protocols, devtools_protocol_handlers, @@ -534,6 +666,40 @@ impl WinitCefApp { let Some(appwindow) = self.state.windows.get_mut(&window_id) else { return; }; + let message = match message { + WebviewMessage::WithWebview(callback) => { + let Some(child) = appwindow + .children + .iter() + .find(|child| child.webview_id == webview_id) + else { + return; + }; + let document = child + .frame_navigation_state + .observe_document(&child.browser); + let dialogs = child.dialogs.snapshot(document.as_ref()); + let snapshot = WebviewSnapshot { + browser_id: child.browser_id, + dialogs, + document, + window_label: Some(appwindow.label.clone()), + window: Some(appwindow.lifetime.clone()), + parent_matches: child.native_parent_matches(appwindow), + bounds: child.bounds(), + visible: child.native_visible(), + }; + let mut native = Webview::new( + child.browser.clone(), + snapshot, + child.frame_navigation_state.clone(), + ); + native.popups = child.popup_family.observe(); + callback(native); + return; + } + message => message, + }; let Some(child) = appwindow .children .iter_mut() @@ -552,12 +718,14 @@ impl WinitCefApp { } WebviewMessage::EvaluateScriptWithCallback(script, callback) => { let host = &child.host; - let message_id = self.context.next_webview_event_id() as i32 + 1; - let message_id = Arc::new(AtomicI32::new(message_id)); + let Ok(message_id) = crate::devtools::allocate_runtime_devtools_message_id() else { + callback(String::new()); + return; + }; let callback = Arc::new(Mutex::new(Some(callback))); let registration = Arc::new(Mutex::new(None)); let mut observer = EvalScriptWithCallbackDevToolsObserver::new( - message_id.clone(), + message_id, callback.clone(), registration.clone(), ); @@ -568,7 +736,7 @@ impl WinitCefApp { *registration.lock().unwrap() = Some(observer_registration); let message = serde_json::json!({ - "id": message_id.load(Ordering::Relaxed), + "id": message_id, "method": "Runtime.evaluate", "params": { "expression": script, @@ -597,19 +765,7 @@ impl WinitCefApp { WebviewMessage::CanGoBack(tx) => _ = tx.send(Ok(child.browser.can_go_back() == 1)), WebviewMessage::GoForward => child.browser.go_forward(), WebviewMessage::CanGoForward(tx) => _ = tx.send(Ok(child.browser.can_go_forward() == 1)), - // Tauri's Webview::close() is an unconditional native lifecycle action, - // not a page-requested window.close(). A non-forced CEF close may leave - // the child browser (and publisher code) alive indefinitely, and its late - // callback can race parent-window bookkeeping. Window/app teardown already - // uses force_close=true; standalone child close needs the same semantics. - WebviewMessage::Close => { - child.host.close_browser(1); - // Windowed CEF browsers are not destroyed by CloseBrowser alone: the - // native child hierarchy must also be torn down before OnBeforeClose - // runs. Leaving it attached leaks the renderer; letting CEF forward a - // close to its top-level parent can close the whole Tauri window. - child.destroy_native(); - } + WebviewMessage::Close => child.host.close_browser(0), WebviewMessage::SetBounds(bounds) => { let parent_size = appwindow.window.surface_size(); let scale = appwindow.window.scale_factor(); @@ -635,10 +791,7 @@ impl WinitCefApp { }; child.set_bounds(parent_size, scale, new_bounds); } - WebviewMessage::SetFocus => { - child.host.set_focus(1); - child.take_input_focus(); - } + WebviewMessage::SetFocus => child.host.set_focus(1), WebviewMessage::Url(tx) => { let url = child.url().unwrap_or_default(); let _ = tx.send(Ok(url)); @@ -658,7 +811,7 @@ impl WinitCefApp { let size = bounds.map(|b| b.size.to_physical::(appwindow.window.scale_factor())); let _ = tx.send(size); } - WebviewMessage::WithWebview(f) => f(Webview::new(child.browser.clone())), + WebviewMessage::WithWebview(_) => unreachable!("native observation dispatched above"), WebviewMessage::Print => child.host.print(), WebviewMessage::AddEventListener(event_id, handler) => { child.listeners.lock().unwrap().insert(event_id, handler); @@ -765,6 +918,11 @@ impl WinitCefApp { target_appwindow.window.scale_factor(), bounds, ); + // Re-parenting does not preserve z-order: a view docked back into a + // window that already owns a full-window main webview must be put back + // on top, or it lands behind it and renders nothing. + #[cfg(windows)] + child.raise_to_top(); target_appwindow.children.push(child); let _ = tx.send(Ok(())); @@ -800,6 +958,7 @@ impl WinitCefApp { &child.browser, child.devtools_protocol_handlers.clone(), Arc::new(Mutex::new(HashMap::new())), + child.dialogs.clone(), ) { *child.devtools_observer_registration.lock().unwrap() = Some(registration); let _ = tx.send(Ok(())); @@ -814,6 +973,41 @@ impl WinitCefApp { } } +#[derive(Clone, Copy, Debug)] +pub enum RuntimeStyle { + Alloy, + Chrome, +} + +/// The CEF-specific webview attributes, set through +/// [`WebviewWindowBuilderCefExt`](crate::WebviewWindowBuilderCefExt). +#[derive(Default, Clone)] +pub struct CefWebviewAttributes { + /// The browser runtime style, see [`RuntimeStyle`]. CEF picks one when not set. + pub runtime_style: Option, + /// Observer of the native lifecycle events of every frame of the webview. + /// + /// Scoped to this webview's own native browser — its main frame and its child + /// frames. Every notification carries that one + /// [`browser_id`](crate::FrameEvent::browser_id). A CEF-owned popup is a + /// separate browser that navigates wherever its own content goes, and a + /// [`FrameEvent`](crate::FrameEvent) carries the full URL, so popups are never + /// reported here. Observe them through [`Webview::popups`], whose + /// [`FrameNavigationState`](crate::FrameNavigationState) follows a popup's + /// native lifecycle without exposing its URLs. + pub frame_event_handler: Option>, +} + +impl std::fmt::Debug for CefWebviewAttributes { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CefWebviewAttributes") + .field("runtime_style", &self.runtime_style) + .field("frame_event_handler", &self.frame_event_handler.is_some()) + .finish() + } +} + #[derive(Debug, Clone)] pub struct CefInitScript { pub(crate) script: String, @@ -822,6 +1016,11 @@ pub struct CefInitScript { } impl CefInitScript { + /// Whether this script must run in a document loaded in the given frame. + pub(crate) fn runs_in_frame(&self, is_main_frame: bool) -> bool { + is_main_frame || !self.for_main_frame_only + } + fn new(script: InitializationScript) -> Self { let mut hasher = Sha256::new(); hasher.update(normalize_script_for_csp(script.script.as_bytes())); @@ -865,6 +1064,15 @@ pub struct CefWebviewDispatcher { } impl CefWebviewDispatcher { + /// Sends a UTF-8 encoded Chrome DevTools Protocol message to the DevTools agent. + /// + /// The message's `id` must come from + /// [`allocate_devtools_message_id`](crate::allocate_devtools_message_id), the + /// allocator every caller on this browser shares. A hardcoded or + /// self-incremented `id` can consume another caller's + /// [`DevToolsProtocol::MethodResult`]. The runtime's own requests use + /// identifiers reserved above that allocator's range, so they cannot be + /// answered by a caller's message. pub fn send_dev_tools_message(&self, message: &[u8]) -> Result<()> { let (tx, rx) = mpsc::channel(); self.context.send_message(Message::Webview { @@ -875,6 +1083,19 @@ impl CefWebviewDispatcher { rx.recv().map_err(|_| Error::FailedToReceiveMessage)? } + /// Observes the [`DevToolsProtocol`] traffic of this browser. + /// + /// The observer receives every message on the browser, including the runtime's + /// own requests, so results must be matched against an identifier obtained from + /// [`allocate_devtools_message_id`](crate::allocate_devtools_message_id). + /// + /// Scoped to this webview's own native browser. A CEF-owned popup is a + /// separate browser whose protocol traffic — its page content, its network + /// activity and its dialog messages — is never reported here, the way a + /// [`FrameEvent`](crate::FrameEvent) of a popup is not. Observe popups + /// through [`Webview::popups`], whose + /// [`FrameNavigationState`](crate::FrameNavigationState) follows a popup's + /// native lifecycle without exposing what it loaded. pub fn on_dev_tools_protocol( &self, f: F, @@ -931,8 +1152,10 @@ fn getter( macro_rules! webview_getter { ($self:ident, $variant:ident) => {{ - let window_id = *$self.window_id.lock().unwrap(); let (tx, rx) = mpsc::channel(); + // Drop the guard before waiting: CEF page-load callbacks on the UI thread + // may need this same lock to dispatch work before servicing the getter. + let window_id = *$self.window_id.lock().unwrap(); getter( &$self.context, Message::Webview { @@ -945,31 +1168,54 @@ macro_rules! webview_getter { }}; } -impl CefWebviewDispatcher { - // History navigation: feat/cef trait methods, not yet part of the published - // `WebviewDispatch` trait — kept as inherent API until upstream releases. - pub fn go_back(&self) -> Result<()> { - self.context.send_message(Message::Webview { - window_id: *self.window_id.lock().unwrap(), - webview_id: self.webview_id, - message: WebviewMessage::GoBack, - }) - } - - pub fn can_go_back(&self) -> Result { - webview_getter!(self, CanGoBack) - } - - pub fn go_forward(&self) -> Result<()> { - self.context.send_message(Message::Webview { - window_id: *self.window_id.lock().unwrap(), - webview_id: self.webview_id, - message: WebviewMessage::GoForward, - }) +#[cfg(test)] +mod getter_tests { + use super::{Message, WebviewMessage}; + use std::sync::{Arc, Mutex, mpsc}; + use tauri_runtime::{Result, window::WindowId}; + + // Exercise the production macro with a bounded UI-reply probe. A real CEF + // event loop cannot run in a unit-test worker; the probe checks the lock + // before replying so the regression fails instead of hanging the suite. + struct Dispatcher { + context: Arc>, + window_id: Arc>, + webview_id: u32, } - pub fn can_go_forward(&self) -> Result { - webview_getter!(self, CanGoForward) + fn getter( + window_id: &Arc>, + message: Message<()>, + receiver: mpsc::Receiver>, + ) -> Result { + let Message::Webview { + window_id: requested_window, + message: WebviewMessage::Url(reply), + .. + } = message + else { + panic!("expected a URL request"); + }; + let callback_window = window_id + .try_lock() + .expect("the UI callback must acquire the window lock before replying"); + assert_eq!(*callback_window, requested_window); + reply.send(Ok("https://example.test/".into())).unwrap(); + receiver.recv().unwrap() + } + + #[test] + fn url_getter_does_not_hold_the_window_lock_while_waiting_for_ui() { + let window_id = Arc::new(Mutex::new(WindowId::from(1))); + let dispatcher = Dispatcher { + context: Arc::clone(&window_id), + window_id, + webview_id: 1, + }; + assert_eq!( + webview_getter!(dispatcher, Url).unwrap(), + "https://example.test/" + ); } } @@ -990,43 +1236,56 @@ impl WebviewDispatch for CefWebviewDispatcher { id } - fn with_webview) + Send + 'static>(&self, f: F) -> Result<()> { - // Published tauri erases the runtime webview type; downcast the boxed - // `Any` back to [`Webview`] to reach the underlying `cef::Browser`. + fn with_webview>::Webview) + Send + 'static>( + &self, + f: F, + ) -> Result<()> { self.context.send_message(Message::Webview { window_id: *self.window_id.lock().unwrap(), webview_id: self.webview_id, - message: WebviewMessage::WithWebview(Box::new(move |webview: Webview| f(Box::new(webview)))), + message: WebviewMessage::WithWebview(Box::new(f)), }) } - #[cfg(any(debug_assertions, feature = "devtools"))] fn open_devtools(&self) { - let _ = self.context.send_message(Message::Webview { - window_id: *self.window_id.lock().unwrap(), - webview_id: self.webview_id, - message: WebviewMessage::OpenDevTools, - }); + #[cfg(any(debug_assertions, feature = "devtools"))] + { + let _ = self.context.send_message(Message::Webview { + window_id: *self.window_id.lock().unwrap(), + webview_id: self.webview_id, + message: WebviewMessage::OpenDevTools, + }); + } + #[cfg(not(any(debug_assertions, feature = "devtools")))] + log::warn!("devtools are not available: enable the `devtools` feature of `tauri-runtime-cef`"); } - #[cfg(any(debug_assertions, feature = "devtools"))] fn close_devtools(&self) { - let _ = self.context.send_message(Message::Webview { - window_id: *self.window_id.lock().unwrap(), - webview_id: self.webview_id, - message: WebviewMessage::CloseDevTools, - }); + #[cfg(any(debug_assertions, feature = "devtools"))] + { + let _ = self.context.send_message(Message::Webview { + window_id: *self.window_id.lock().unwrap(), + webview_id: self.webview_id, + message: WebviewMessage::CloseDevTools, + }); + } } - #[cfg(any(debug_assertions, feature = "devtools"))] fn is_devtools_open(&self) -> Result { - let (tx, rx) = mpsc::channel(); - self.context.send_message(Message::Webview { - window_id: *self.window_id.lock().unwrap(), - webview_id: self.webview_id, - message: WebviewMessage::IsDevToolsOpen(tx), - })?; - rx.recv().map_err(|_| Error::FailedToReceiveMessage) + #[cfg(any(debug_assertions, feature = "devtools"))] + { + let (tx, rx) = mpsc::channel(); + self.context.send_message(Message::Webview { + window_id: *self.window_id.lock().unwrap(), + webview_id: self.webview_id, + message: WebviewMessage::IsDevToolsOpen(tx), + })?; + rx.recv().map_err(|_| Error::FailedToReceiveMessage) + } + #[cfg(not(any(debug_assertions, feature = "devtools")))] + { + Ok(false) + } } fn url(&self) -> Result { @@ -1061,6 +1320,30 @@ impl WebviewDispatch for CefWebviewDispatcher { }) } + fn go_back(&self) -> Result<()> { + self.context.send_message(Message::Webview { + window_id: *self.window_id.lock().unwrap(), + webview_id: self.webview_id, + message: WebviewMessage::GoBack, + }) + } + + fn can_go_back(&self) -> Result { + webview_getter!(self, CanGoBack) + } + + fn go_forward(&self) -> Result<()> { + self.context.send_message(Message::Webview { + window_id: *self.window_id.lock().unwrap(), + webview_id: self.webview_id, + message: WebviewMessage::GoForward, + }) + } + + fn can_go_forward(&self) -> Result { + webview_getter!(self, CanGoForward) + } + fn print(&self) -> Result<()> { self.context.send_message(Message::Webview { window_id: *self.window_id.lock().unwrap(), @@ -1296,17 +1579,21 @@ pub(crate) const INITIAL_LOAD_URL: &str = concat!( "%3C%2Fbody%3E", "%3C%2Fhtml%3E", ); -static NEXT_INIT_SCRIPT_DEVTOOLS_MESSAGE_ID: AtomicI32 = AtomicI32::new(1_000_000); /// Maps a pending `Page.addScriptToEvaluateOnNewDocument` CDP message id to the /// `(browser, real_url)` whose real navigation is deferred until that message is /// acknowledged. +/// +/// The keys only ever come from `allocate_runtime_devtools_message_id`, whose +/// reserved range no caller identifier can reach, so a caller cannot release the +/// deferred navigation early by sending a request with a hardcoded `id`. pub(crate) type PendingInitialLoads = Arc>>; cef::wrap_dev_tools_message_observer! { struct TauriDevToolsProtocolObserver { handlers: Arc>>>, pending_initial_loads: PendingInitialLoads, + dialogs: crate::dialog::DialogState, } impl DevToolsMessageObserver { @@ -1358,10 +1645,14 @@ cef::wrap_dev_tools_message_observer! { fn on_dev_tools_event( &self, - _browser: Option<&mut Browser>, + browser: Option<&mut Browser>, method: Option<&CefString>, params: Option<&[u8]>, ) { + if let (Some(browser), Some(method)) = (browser, method) + && self.dialogs.accepts_browser(browser.identifier()) { + self.dialogs.on_event(&method.to_string(), params.unwrap_or_default()); + } let protocol = DevToolsProtocol::Event { method: method.map(|m| format!("{m}")).unwrap_or_default(), params: params.map(|p| p.to_vec()).unwrap_or_default(), @@ -1398,7 +1689,7 @@ type EvalScriptCallback = Box; cef::wrap_dev_tools_message_observer! { struct EvalScriptWithCallbackDevToolsObserver { - message_id: Arc, + message_id: i32, callback: Arc>>, registration: Arc>>, } @@ -1411,7 +1702,7 @@ cef::wrap_dev_tools_message_observer! { success: std::os::raw::c_int, result: Option<&[u8]>, ) { - if message_id != self.message_id.load(Ordering::Relaxed) { + if message_id != self.message_id { return; } @@ -1438,10 +1729,16 @@ pub(crate) fn add_dev_tools_observer( browser: &Browser, handlers: Arc>>>, pending_initial_loads: PendingInitialLoads, + dialogs: crate::dialog::DialogState, ) -> Option { browser.host().and_then(|host| { - let mut observer = TauriDevToolsProtocolObserver::new(handlers, pending_initial_loads); - host.add_dev_tools_message_observer(Some(&mut observer)) + let mut observer = TauriDevToolsProtocolObserver::new(handlers, pending_initial_loads, dialogs); + let registration = host.add_dev_tools_message_observer(Some(&mut observer))?; + if let Ok(id) = crate::devtools::allocate_runtime_devtools_message_id() { + let message = serde_json::json!({"id":id,"method":"Page.enable","params":{}}).to_string(); + let _ = host.send_dev_tools_message(Some(message.as_bytes())); + } + Some(registration) }) } @@ -1494,28 +1791,19 @@ fn register_initialization_scripts( custom_scheme_domain_names: &[String], initial_url: String, pending_initial_loads: &PendingInitialLoads, -) -> bool { +) -> std::result::Result { let Some(source) = devtools_initialization_script_source( initialization_scripts, custom_protocol_scheme, custom_scheme_domain_names, ) else { - return false; + return Ok(false); }; let Some(host) = browser.host() else { - return false; + return Ok(false); }; - let page_enable_message_id = NEXT_INIT_SCRIPT_DEVTOOLS_MESSAGE_ID.fetch_add(1, Ordering::Relaxed); - let page_enable_message = serde_json::json!({ - "id": page_enable_message_id, - "method": "Page.enable", - "params": {} - }) - .to_string(); - let _ = host.send_dev_tools_message(Some(page_enable_message.as_bytes())); - - let message_id = NEXT_INIT_SCRIPT_DEVTOOLS_MESSAGE_ID.fetch_add(1, Ordering::Relaxed); + let message_id = crate::devtools::allocate_runtime_devtools_message_id()?; let message = serde_json::json!({ "id": message_id, "method": "Page.addScriptToEvaluateOnNewDocument", @@ -1530,10 +1818,10 @@ fn register_initialization_scripts( .unwrap() .insert(message_id, (browser.clone(), initial_url)); if host.send_dev_tools_message(Some(message.as_bytes())) == 1 { - true + Ok(true) } else { pending_initial_loads.lock().unwrap().remove(&message_id); - false + Ok(false) } } @@ -1582,8 +1870,17 @@ pub(crate) fn load_initial_url_after_registering_initialization_scripts( pending_initial_loads, ); - if !is_waiting_for_initialization_scripts { - post_load_initial_url(browser_for_callback, initial_url); + match is_waiting_for_initialization_scripts { + Ok(false) => post_load_initial_url(browser_for_callback, initial_url), + Ok(true) => {} + Err(error) => { + // Exhaustion cannot fall through to a navigation without the requested + // document-start scripts or reuse another operation's acknowledgment. + log::error!("CEF initialization failed: {error}"); + if let Some(host) = browser.host() { + host.close_browser(1); + } + } } } diff --git a/src/window.rs b/src/window.rs index fa2d25f..6868125 100644 --- a/src/window.rs +++ b/src/window.rs @@ -8,6 +8,7 @@ use std::{ Arc, Mutex, mpsc::{self, Receiver, Sender}, }, + time::{Duration, Instant}, }; use cef::ImplBrowserHost; @@ -30,14 +31,10 @@ use winit::{ window::{Window as WinitWindow, WindowAttributes, WindowLevel}, }; -#[cfg(target_os = "macos")] -use crate::platform::macos::AppkitState; use crate::platform::{EventLoopExt, MonitorExt}; #[cfg(any(windows, target_os = "macos"))] use std::marker::PhantomData; #[cfg(target_os = "macos")] -use std::sync::RwLock; -#[cfg(target_os = "macos")] use winit::platform::macos::WindowExtMacOS; #[cfg(windows)] use winit::platform::windows::WindowExtWindows; @@ -214,9 +211,10 @@ fn prepare_window_attributes(event_loop: &dyn ActiveEventLoop, attrs: &mut AppWi } } -pub(crate) fn paired_size_constraint( +fn paired_size_constraint( width: Option, height: Option, + unconstrained: u32, ) -> Option { match (width, height) { ( @@ -233,10 +231,36 @@ pub(crate) fn paired_size_constraint( width.into(), height.into(), ))), + (Some(tauri_runtime::dpi::PixelUnit::Logical(width)), None) => Some(Size::Logical( + tauri_runtime::dpi::LogicalSize::new(width.into(), unconstrained as f64), + )), + (None, Some(tauri_runtime::dpi::PixelUnit::Logical(height))) => Some(Size::Logical( + tauri_runtime::dpi::LogicalSize::new(unconstrained as f64, height.into()), + )), + (Some(tauri_runtime::dpi::PixelUnit::Physical(width)), None) => Some(Size::Physical( + PhysicalSize::new(width.into(), unconstrained), + )), + (None, Some(tauri_runtime::dpi::PixelUnit::Physical(height))) => Some(Size::Physical( + PhysicalSize::new(unconstrained, height.into()), + )), _ => None, } } +pub(crate) fn min_size_constraint( + width: Option, + height: Option, +) -> Option { + paired_size_constraint(width, height, 0) +} + +pub(crate) fn max_size_constraint( + width: Option, + height: Option, +) -> Option { + paired_size_constraint(width, height, u32::MAX) +} + pub(crate) enum WindowMessage { AddEventListener(WindowEventId, WindowEventListener), Close, @@ -319,7 +343,55 @@ pub(crate) enum WindowMessage { #[cfg(windows)] type SoftbufferSurface = softbuffer::Surface; +/// Opaque identity of one runtime-owned native window lifetime. Reparented +/// webviews observe the destination token; same-label replacements never match. +#[derive(Clone)] +pub struct NativeWindowToken(Arc<()>); + +impl NativeWindowToken { + pub(crate) fn new() -> Self { + Self(Arc::new(())) + } +} + +impl PartialEq for NativeWindowToken { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for NativeWindowToken {} + +impl std::fmt::Debug for NativeWindowToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NativeWindowToken") + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod native_window_identity_tests { + use super::NativeWindowToken; + + #[test] + fn references_preserve_one_window_and_reject_replacements() { + let first = NativeWindowToken::new(); + let retained_by_webview = first.clone(); + assert_eq!(first, retained_by_webview); + let destination = NativeWindowToken::new(); + assert_ne!(retained_by_webview, destination); + drop(first); + assert_ne!(retained_by_webview, NativeWindowToken::new()); + } +} + +/// How long to keep retrying the initial raise of a window created focused +/// before assuming it is never going to be mapped. +const PENDING_ACTIVATION_TIMEOUT: Duration = Duration::from_secs(5); + pub(crate) struct AppWindow { + pub(crate) lifetime: NativeWindowToken, #[allow(unused)] pub(crate) id: WindowId, pub(crate) label: String, @@ -329,10 +401,10 @@ pub(crate) struct AppWindow { pub(crate) attrs: AppWindowAttrs, pub(crate) children: Vec, pub(crate) listeners: WindowEventListeners, - /// Last focus state reported to Tauri. See `WinitCefApp::sync_window_focus`. - pub(crate) reported_focus: bool, - #[cfg(target_os = "macos")] - pub(crate) appkit_state: Arc>, + /// Deadline for the initial raise of a window created focused, see + /// [`WinitCefApp::apply_pending_activations`]. `None` once it has been + /// raised or given up on. + pub(crate) pending_activation: Option, } #[derive(Clone, Debug, Default)] @@ -387,6 +459,30 @@ impl AppWindow { self.window.set_outer_position(Position::Physical(position)); } + /// Bring the window to the front and give it the input focus. + /// + /// `WinitWindow::focus_window` alone is not enough: on macOS it asks for + /// activation through the deprecated `activateIgnoringOtherApps:`, which + /// macOS 14+ ignores, and on X11 it asks the window manager to activate with + /// the "application" source indication, which focus-stealing prevention + /// routinely downgrades to a taskbar highlight. Both get a native nudge + /// first. + pub(crate) fn activate(&self) { + #[cfg(target_os = "macos")] + crate::platform::macos::activate_application(); + + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + self.raise_native(); + + self.window.focus_window(); + } + pub(crate) fn preferred_theme(&self) -> Option { self .attrs @@ -403,6 +499,8 @@ impl AppWindow { self.attrs.inner.preferred_theme = tauri_theme_to_winit_theme(theme); self.window.set_theme(tauri_theme_to_winit_theme(theme)); self.apply_cef_theme(theme); + #[cfg(target_os = "macos")] + self.reapply_traffic_light_position_after_appearance_change(); } fn apply_cef_theme(&self, theme: Option) { @@ -434,7 +532,10 @@ impl WinitCefApp { .map_err(|_| Error::CreateWindow)?; let winit_id = window.id(); + let pending_activation = (attrs.inner.active && attrs.inner.visible) + .then(|| Instant::now() + PENDING_ACTIVATION_TIMEOUT); let mut appwindow = AppWindow { + lifetime: NativeWindowToken::new(), id: window_id, label: pending.label.clone(), #[cfg(windows)] @@ -443,14 +544,11 @@ impl WinitCefApp { attrs, children: Vec::new(), listeners: Default::default(), - reported_focus: false, - #[cfg(target_os = "macos")] - appkit_state: Arc::new(RwLock::new(AppkitState::default())), + pending_activation, }; #[cfg(target_os = "macos")] { - appwindow.associate_appkit_state(); appwindow.set_visible_on_all_workspaces(appwindow.attrs.visible_on_all_workspaces); if let Some(position) = &appwindow.attrs.traffic_light_position { appwindow.set_traffic_light_position(position); @@ -470,9 +568,7 @@ impl WinitCefApp { } #[cfg(windows)] - if appwindow.attrs.inner.transparent || appwindow.attrs.background_color.is_some() { - appwindow.draw_background_surface(); - } + appwindow.draw_background_surface(); #[cfg(not(windows))] if appwindow.attrs.background_color.is_some() { @@ -512,6 +608,41 @@ impl WinitCefApp { Ok(()) } + /// Bring windows that were created focused to the front. + /// + /// winit applies [`WindowAttributes::active`] unevenly: X11 ignores it + /// outright, and on macOS/Windows it only orders the window front *within* + /// the application without pulling the process to the foreground. A window + /// created while another app owns the foreground - a terminal running + /// `tauri dev`, say - is then left buried behind it. Raising it ourselves + /// once it is on screen makes the initial activation deterministic. + /// + /// `focus_window` is a no-op while the backend still considers the window + /// unmapped (X11 only reports it visible once the server sends + /// `VisibilityNotify`, which lands after `create_window` returns), so keep + /// the request pending until winit reports the window visible, and drop it + /// after [`PENDING_ACTIVATION_TIMEOUT`] so a window that never maps does not + /// pop to the front minutes later. + pub(crate) fn apply_pending_activations(&mut self) { + let now = Instant::now(); + for appwindow in self.state.windows.values_mut() { + let Some(deadline) = appwindow.pending_activation else { + continue; + }; + + if appwindow.window.is_visible() == Some(false) { + if now < deadline { + continue; + } + appwindow.pending_activation = None; + continue; + } + + appwindow.activate(); + appwindow.pending_activation = None; + } + } + pub(crate) fn handle_window_message( &mut self, event_loop: &dyn ActiveEventLoop, @@ -540,9 +671,7 @@ impl WinitCefApp { WindowMessage::AddEventListener(id, listener) => { appwindow.listeners.lock().unwrap().insert(id, listener); } - WindowMessage::Close | WindowMessage::Destroy => { - unreachable!("handled before borrowing") - } + WindowMessage::Close | WindowMessage::Destroy => unreachable!("handled before borrowing"), WindowMessage::ScaleFactor(tx) => _ = tx.send(Ok(window.scale_factor())), WindowMessage::InnerSize(tx) => _ = tx.send(Ok(window.surface_size())), WindowMessage::OuterSize(tx) => _ = tx.send(Ok(window.outer_size())), @@ -660,7 +789,7 @@ impl WinitCefApp { WindowMessage::SetSimpleFullscreen(value) => { window.set_simple_fullscreen(value); } - WindowMessage::SetFocus => window.focus_window(), + WindowMessage::SetFocus => appwindow.activate(), WindowMessage::SetMinSize(min_size) => window.set_min_surface_size(min_size), WindowMessage::SetMaxSize(max_size) => window.set_max_surface_size(max_size), WindowMessage::SetMaximizable(value) => { @@ -753,7 +882,7 @@ impl WinitCefApp { WindowMessage::SetTrafficLightPosition(_position) => { #[cfg(target_os = "macos")] { - appwindow.attrs.traffic_light_position = Some(_position.clone()); + appwindow.attrs.traffic_light_position = Some(_position); appwindow.set_traffic_light_position(&_position); } } @@ -780,8 +909,8 @@ impl WinitCefApp { } WindowMessage::SetSizeConstraints(constraints) => { // TODO: upstream individual width/height size constraints to winit. - let min_size = paired_size_constraint(constraints.min_width, constraints.min_height); - let max_size = paired_size_constraint(constraints.max_width, constraints.max_height); + let min_size = min_size_constraint(constraints.min_width, constraints.min_height); + let max_size = max_size_constraint(constraints.max_width, constraints.max_height); window.set_min_surface_size(min_size); window.set_max_surface_size(max_size); } @@ -1211,7 +1340,7 @@ impl WindowDispatch for CefWindowDispatcher { fn set_icon(&self, icon: Icon) -> Result<()> { self.context.send_message(Message::Window { window_id: self.window_id, - message: WindowMessage::SetIcon(crate::compat::icon_into_owned(icon)), + message: WindowMessage::SetIcon(icon.into_owned()), }) } @@ -1288,7 +1417,7 @@ impl WindowDispatch for CefWindowDispatcher { fn set_overlay_icon(&self, icon: Option) -> Result<()> { self.context.send_message(Message::Window { window_id: self.window_id, - message: WindowMessage::SetOverlayIcon(icon.map(crate::compat::icon_into_owned)), + message: WindowMessage::SetOverlayIcon(icon.map(Icon::into_owned)), }) } @@ -1339,16 +1468,17 @@ where { let label = pending.label.clone(); let window_id = context.next_window_id(); - let (webview_id, use_https_scheme) = pending + let (webview_id, use_https_scheme, devtools) = pending .webview .as_ref() .map(|w| { ( Some(context.next_webview_id()), w.webview_attributes.use_https_scheme, + w.webview_attributes.devtools, ) }) - .unwrap_or((None, false)); + .unwrap_or((None, false, None)); let (result_tx, result_rx) = mpsc::channel(); context.send_message(Message::CreateWindow { @@ -1374,6 +1504,7 @@ where }, }, use_https_scheme, + devtools, }); Ok(DetachedWindow { diff --git a/src/window_builder.rs b/src/window_builder.rs index 9164cf0..457ae22 100644 --- a/src/window_builder.rs +++ b/src/window_builder.rs @@ -18,7 +18,8 @@ use winit::{ }; use crate::window::{ - AppWindowAttrs, paired_size_constraint, tauri_theme_to_winit_theme, winit_theme_to_tauri_theme, + AppWindowAttrs, max_size_constraint, min_size_constraint, tauri_theme_to_winit_theme, + winit_theme_to_tauri_theme, }; #[cfg(any(windows, target_os = "macos"))] @@ -94,12 +95,20 @@ impl WindowBuilder for WindowBuilderWrapper { { builder = builder.transparent(config.transparent); } - if let (Some(min_width), Some(min_height)) = (config.min_width, config.min_height) { - builder = builder.min_inner_size(min_width, min_height); + let mut constraints = WindowSizeConstraints::default(); + if let Some(min_width) = config.min_width { + constraints.min_width = Some(tauri_runtime::dpi::LogicalUnit::new(min_width).into()); } - if let (Some(max_width), Some(max_height)) = (config.max_width, config.max_height) { - builder = builder.max_inner_size(max_width, max_height); + if let Some(min_height) = config.min_height { + constraints.min_height = Some(tauri_runtime::dpi::LogicalUnit::new(min_height).into()); } + if let Some(max_width) = config.max_width { + constraints.max_width = Some(tauri_runtime::dpi::LogicalUnit::new(max_width).into()); + } + if let Some(max_height) = config.max_height { + constraints.max_height = Some(tauri_runtime::dpi::LogicalUnit::new(max_height).into()); + } + builder = builder.inner_size_constraints(constraints); if let Some(color) = config.background_color { builder = builder.background_color(color); } @@ -179,11 +188,10 @@ impl WindowBuilder for WindowBuilderWrapper { } fn inner_size_constraints(mut self, constraints: WindowSizeConstraints) -> Self { - // TODO: upstream individual width/height size constraints to winit. self.attrs.inner.min_surface_size = - paired_size_constraint(constraints.min_width, constraints.min_height); + min_size_constraint(constraints.min_width, constraints.min_height); self.attrs.inner.max_surface_size = - paired_size_constraint(constraints.max_width, constraints.max_height); + max_size_constraint(constraints.max_width, constraints.max_height); self } @@ -341,6 +349,9 @@ impl WindowBuilder for WindowBuilderWrapper { self.attrs.skip_taskbar = skip; } + #[cfg(target_os = "macos")] + let _skip = skip; + self } @@ -407,12 +418,28 @@ impl WindowBuilder for WindowBuilderWrapper { #[cfg(target_os = "macos")] fn parent(mut self, parent: *mut std::ffi::c_void) -> Self { - if let Some(ns_view) = NonNull::new(parent) { - let handle = - RawWindowHandle::AppKit(winit::raw_window_handle::AppKitWindowHandle::new(ns_view)); - // SAFETY: Tauri passes a live parent NSView owned by the application. - self.attrs.inner = unsafe { self.attrs.inner.with_parent_window(Some(handle)) }; - } + use objc2::rc::Retained; + use objc2_app_kit::{NSView, NSWindow}; + + let Some(nswindow) = NonNull::new(parent) else { + return self; + }; + let Some(nswindow) = (unsafe { Retained::::from_raw(nswindow.as_ptr() as _) }) else { + return self; + }; + + let Some(nsview) = nswindow.contentView() else { + return self; + }; + let nsview = Retained::::into_raw(nsview); + let Some(nsview) = NonNull::new(nsview as _) else { + return self; + }; + + let handle = winit::raw_window_handle::AppKitWindowHandle::new(nsview); + let handle = RawWindowHandle::AppKit(handle); + self.attrs.inner = unsafe { self.attrs.inner.with_parent_window(Some(handle)) }; + self } @@ -498,6 +525,11 @@ impl WindowBuilder for WindowBuilderWrapper { self } + // TODO + fn no_redirection_bitmap(#[allow(unused_mut)] mut self, _enable: bool) -> Self { + self + } + fn has_icon(&self) -> bool { self.attrs.inner.window_icon.is_some() } diff --git a/tests/macos-application-bootstrap.rs b/tests/macos-application-bootstrap.rs new file mode 100644 index 0000000..2853270 --- /dev/null +++ b/tests/macos-application-bootstrap.rs @@ -0,0 +1,56 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// AppKit requires the process main thread, which the standard test harness does not use. +#[cfg(target_os = "macos")] +fn main() { + use objc2::{ClassType, MainThreadMarker, msg_send, runtime::Bool}; + use objc2_app_kit::NSApplication; + + let mtm = MainThreadMarker::new().expect("test must run on the main thread"); + if std::env::args().nth(1).as_deref() == Some("--existing-application") { + let _ = NSApplication::sharedApplication(mtm); + assert!(std::panic::catch_unwind(tauri_runtime_cef::prepare_macos_application).is_err()); + return; + } + assert!( + std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--existing-application") + .status() + .unwrap() + .success() + ); + + assert!( + std::thread::spawn(tauri_runtime_cef::prepare_macos_application) + .join() + .is_err() + ); + + tauri_runtime_cef::prepare_macos_application(); + let application = NSApplication::sharedApplication(mtm); + assert!(!std::ptr::eq(application.class(), NSApplication::class())); + + // Native startup UI can now request the singleton without replacing CEF's application. + let native_application = NSApplication::sharedApplication(mtm); + assert!(std::ptr::eq(&*application, &*native_application)); + tauri_runtime_cef::prepare_macos_application(); + assert!(std::ptr::eq( + &*application, + &*NSApplication::sharedApplication(mtm) + )); + + // CEF's event scopes use these selectors even before an event-loop delegate exists. + unsafe { + let handling: Bool = msg_send![&*application, isHandlingSendEvent]; + assert!(!handling.as_bool()); + let _: () = msg_send![&*application, setHandlingSendEvent: Bool::YES]; + let handling: Bool = msg_send![&*application, isHandlingSendEvent]; + assert!(handling.as_bool()); + let _: () = msg_send![&*application, setHandlingSendEvent: Bool::NO]; + } +} + +#[cfg(not(target_os = "macos"))] +fn main() {}