From b55ab0b0789c1b83c23f63316fd478801211d3d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:30:24 +0000 Subject: [PATCH] chore: sync from tauri-apps/tauri feat/cef --- Cargo.toml | 65 +- build.rs | 10 + src/cef_impl/client/command.rs | 318 +++ src/cef_impl/client/context_menu.rs | 196 +- src/cef_impl/client/display.rs | 53 +- src/cef_impl/client/download.rs | 2 +- src/cef_impl/client/drag.rs | 9 +- src/cef_impl/client/frame.rs | 37 + src/cef_impl/client/life_span.rs | 193 +- src/cef_impl/client/load.rs | 62 +- src/cef_impl/client/mod.rs | 200 +- src/cef_impl/client/permission.rs | 569 ++++-- src/cef_impl/ipc.rs | 41 +- src/cef_impl/mod.rs | 1 + src/cef_impl/preferences.rs | 296 +++ src/cef_impl/request_context.rs | 121 +- src/cef_impl/request_handler.rs | 675 ++++--- src/devtools.rs | 127 ++ src/dialog.rs | 207 ++ src/environment.rs | 202 ++ src/external_message_pump/linux.rs | 18 +- src/frame.rs | 79 + src/frame_navigation.rs | 450 +++++ src/lib.rs | 54 +- src/locale.rs | 233 +++ src/macros.rs | 84 + src/platform/linux/mod.rs | 2 +- src/platform/linux/utils.rs | 100 +- src/platform/linux/webview.rs | 75 +- src/platform/linux/window.rs | 373 +++- src/platform/macos/application.rs | 50 +- src/platform/macos/mod.rs | 6 +- src/platform/macos/utils.rs | 27 +- src/platform/macos/webview.rs | 34 +- src/platform/macos/window.rs | 200 +- src/platform/windows/icon.rs | 2 +- src/platform/windows/webview.rs | 106 +- src/platform/windows/window.rs | 35 +- src/popup.rs | 467 +++++ src/runtime.rs | 2686 +++++++++++++++++++++----- src/sandbox.rs | 539 ++++++ src/switches.rs | 268 +++ src/tauri_ext.rs | 524 +++++ src/webview.rs | 1194 +++++++++--- src/window.rs | 403 +++- src/window_builder.rs | 68 +- tests/macos-application-bootstrap.rs | 56 + 47 files changed, 9669 insertions(+), 1848 deletions(-) create mode 100644 build.rs create mode 100644 src/cef_impl/client/command.rs create mode 100644 src/cef_impl/client/frame.rs create mode 100644 src/cef_impl/preferences.rs create mode 100644 src/devtools.rs create mode 100644 src/dialog.rs create mode 100644 src/environment.rs create mode 100644 src/frame.rs create mode 100644 src/frame_navigation.rs create mode 100644 src/locale.rs create mode 100644 src/macros.rs create mode 100644 src/popup.rs create mode 100644 src/sandbox.rs create mode 100644 src/switches.rs create mode 100644 src/tauri_ext.rs create mode 100644 tests/macos-application-bootstrap.rs diff --git a/Cargo.toml b/Cargo.toml index f53c938..126fac6 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, features = ["gtk4"] } +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,14 @@ 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 = { version = "0.31.0-beta.2", git = "https://github.com/tauri-apps/winit-gtk4", branch = "master", default-features = false, features = [ + "gtk4", +] } [target."cfg(windows)".dependencies] softbuffer = { version = "0.4", default-features = false } @@ -40,6 +48,7 @@ windows = { version = "0.61", features = [ "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_System_Com", + "Win32_Globalization", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", @@ -47,7 +56,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 +86,13 @@ 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", + # NSLocale gives the user's preferred languages, which become the + # Accept-Language list; without it every user would report en-US. + "NSLocale", + "NSNotification", "NSObject", "NSValue", "NSRunLoop", @@ -89,16 +104,18 @@ objc2-foundation = { version = "0.3", default-features = false, features = [ [target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] dlopen2 = { version = "0.8", features = ["derive"] } -gtk = { version = "0.18", features = ["v3_24"] } +gtk = { package = "gtk4", version = "0.11.4" } libc = "0.2" x11-dl = "2.21" [features] -default = ["sandbox"] -devtools = [] -macos-private-api = ["tauri-runtime/macos-private-api"] -sandbox = ["cef/sandbox"] - -[dev-dependencies] -tauri = { version = "2", default-features = false, features = ["test"] } -tempfile = "3" +default = [] +# 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", +] +# 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/command.rs b/src/cef_impl/client/command.rs new file mode 100644 index 0000000..50bef8c --- /dev/null +++ b/src/cef_impl/client/command.rs @@ -0,0 +1,318 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::ffi::CStr; +use std::os::raw::c_int; +use std::sync::OnceLock; + +use cef::*; + +use crate::ChromeCommandGroup; +use crate::macros::wrap_with_args; + +/// Commands that open a second browser window or drive a tab strip. +/// +/// A Chrome style browser keeps its whole accelerator table live even when it is +/// hosted as a child view with no browser UI, so Ctrl+N opens a real Chrome window +/// next to the app's and Ctrl+T a tab in a window the app does not own. An app +/// window has no tab strip for the rest to act on. +const WINDOW_AND_TAB_COMMANDS: &[&CStr] = &[ + cef::resources::IDC_NEW_WINDOW, + cef::resources::IDC_NEW_INCOGNITO_WINDOW, + cef::resources::IDC_NEW_TAB, + cef::resources::IDC_NEW_TAB_TO_RIGHT, + cef::resources::IDC_DUPLICATE_TAB, + cef::resources::IDC_RESTORE_TAB, + cef::resources::IDC_MOVE_TAB_TO_NEW_WINDOW, + cef::resources::IDC_MOVE_TAB_NEXT, + cef::resources::IDC_MOVE_TAB_PREVIOUS, + cef::resources::IDC_SHOW_AS_TAB, + cef::resources::IDC_SELECT_NEXT_TAB, + cef::resources::IDC_SELECT_PREVIOUS_TAB, + cef::resources::IDC_SELECT_LAST_TAB, + cef::resources::IDC_SELECT_TAB_0, + cef::resources::IDC_SELECT_TAB_1, + cef::resources::IDC_SELECT_TAB_2, + cef::resources::IDC_SELECT_TAB_3, + cef::resources::IDC_SELECT_TAB_4, + cef::resources::IDC_SELECT_TAB_5, + cef::resources::IDC_SELECT_TAB_6, + cef::resources::IDC_SELECT_TAB_7, + cef::resources::IDC_TAB_SEARCH, +]; + +/// Commands that treat the app's UI as a web document to be exported. +/// +/// Printing, saving or viewing the source of a Tauri window hands the user the +/// app's own bundled markup, and `IDC_OPEN_FILE` replaces that UI with a local +/// document in the same webview. `WebviewDispatch::print` still prints on request. +const DOCUMENT_COMMANDS: &[&CStr] = &[ + cef::resources::IDC_PRINT, + cef::resources::IDC_BASIC_PRINT, + cef::resources::IDC_SAVE_PAGE, + cef::resources::IDC_VIEW_SOURCE, + cef::resources::IDC_OPEN_FILE, + cef::resources::IDC_CREATE_SHORTCUT, + cef::resources::IDC_INSTALL_PWA, +]; + +/// Commands that move focus into browser chrome the window does not have. +/// +/// An app window has no omnibox, search box or bookmark bar, so Ctrl+L and its +/// neighbours only take keyboard focus somewhere the user cannot see. `IDC_HOME` +/// and `IDC_OPEN_CURRENT_URL` additionally navigate the webview away from the +/// app's UI. +const BROWSER_CHROME_COMMANDS: &[&CStr] = &[ + cef::resources::IDC_FOCUS_LOCATION, + cef::resources::IDC_FOCUS_SEARCH, + cef::resources::IDC_FOCUS_TOOLBAR, + cef::resources::IDC_FOCUS_MENU_BAR, + cef::resources::IDC_FOCUS_BOOKMARKS, + cef::resources::IDC_OPEN_CURRENT_URL, + cef::resources::IDC_HOME, + cef::resources::IDC_SEARCH, + cef::resources::IDC_SHOW_APP_MENU, +]; + +/// Commands that walk the webview's session history. +/// +/// The browser is created at `INITIAL_LOAD_URL`, an internal placeholder, and +/// only then navigated to the app's own URL, so the very first screen already +/// sits on a second history entry and Alt+Left lands on a blank page with no way +/// back. `context_menu.rs` drops Back and Forward for the same reason; +/// `WebviewDispatch::go_back` and `go_forward` call the browser directly and +/// never reach the accelerator table. +const HISTORY_COMMANDS: &[&CStr] = &[cef::resources::IDC_BACK, cef::resources::IDC_FORWARD]; + +/// Commands that open one of Chrome's own profile-wide surfaces. +/// +/// History, downloads, bookmarks, settings, the task manager and the rest load +/// Chrome WebUI pages *in place of the app's UI*, in the very webview the +/// accelerator was pressed in, and expose the browsing data of every webview +/// sharing the request context. +const BROWSER_SURFACE_COMMANDS: &[&CStr] = &[ + cef::resources::IDC_SHOW_HISTORY, + cef::resources::IDC_SHOW_DOWNLOADS, + cef::resources::IDC_SHOW_BOOKMARK_MANAGER, + cef::resources::IDC_SHOW_BOOKMARK_BAR, + cef::resources::IDC_BOOKMARK_THIS_TAB, + cef::resources::IDC_BOOKMARK_ALL_TABS, + cef::resources::IDC_OPTIONS, + cef::resources::IDC_CLEAR_BROWSING_DATA, + cef::resources::IDC_IMPORT_SETTINGS, + cef::resources::IDC_TASK_MANAGER, + cef::resources::IDC_TASK_MANAGER_SHORTCUT, + cef::resources::IDC_SHOW_SIGNIN, + cef::resources::IDC_ABOUT, + cef::resources::IDC_FEEDBACK, + cef::resources::IDC_HELP_PAGE_VIA_KEYBOARD, +]; + +/// Commands that open DevTools. +/// +/// Blocked only when the webview disabled devtools. `keyboard.rs` blocks F12 and +/// the inspect chord by key code; these cover the rest of the accelerator table. +/// +/// Left to Chrome otherwise: these accelerators *are* the DevTools shortcut for a +/// Chrome style browser. The page script that binds the chord elsewhere - +/// `tauri_runtime::webview::devtools_shortcut_script` - is injected only into Alloy +/// style webviews, which keep none of this accelerator table, so nothing competes here. +const DEVTOOLS_COMMANDS: &[&CStr] = &[ + cef::resources::IDC_DEV_TOOLS, + cef::resources::IDC_DEV_TOOLS_CONSOLE, + cef::resources::IDC_DEV_TOOLS_DEVICES, + cef::resources::IDC_DEV_TOOLS_INSPECT, + cef::resources::IDC_DEV_TOOLS_TOGGLE, +]; + +/// Commands that change the page zoom. +/// +/// Blocked only when the webview set `zoom_hotkeys_enabled` to false, which is +/// exactly what that attribute asks for — note that it **defaults to false**. +/// `WebviewDispatch::set_zoom` still zooms on the application's own request. +/// +/// Two things this does not reach: Ctrl+mouse-wheel zoom, which Chromium applies +/// in the render widget rather than through the command controller, and the zoom +/// polyfill `tauri` injects on Linux and macOS when the attribute is true, which +/// makes a keyboard zoom step twice there. +const ZOOM_COMMANDS: &[&CStr] = &[ + cef::resources::IDC_ZOOM_PLUS, + cef::resources::IDC_ZOOM_MINUS, + cef::resources::IDC_ZOOM_NORMAL, +]; + +/// The IDC names of one [`ChromeCommandGroup`]. +fn group_commands(group: ChromeCommandGroup) -> &'static [&'static CStr] { + match group { + ChromeCommandGroup::WindowAndTab => WINDOW_AND_TAB_COMMANDS, + ChromeCommandGroup::Document => DOCUMENT_COMMANDS, + ChromeCommandGroup::BrowserChrome => BROWSER_CHROME_COMMANDS, + ChromeCommandGroup::BrowserSurface => BROWSER_SURFACE_COMMANDS, + ChromeCommandGroup::History => HISTORY_COMMANDS, + } +} + +/// This build's numeric ids for the commands the runtime can swallow, one entry per +/// group so the webview's allowlist can be applied per group. +/// +/// Chrome command ids are build-specific integers, so they are resolved from their +/// IDC names — which do not change between builds — once for the whole process +/// rather than on every keystroke. +struct BlockedCommands { + groups: Vec<(ChromeCommandGroup, Vec)>, + devtools: Vec, + zoom: Vec, +} + +fn blocked_commands() -> &'static BlockedCommands { + static COMMANDS: OnceLock = OnceLock::new(); + COMMANDS.get_or_init(|| BlockedCommands { + groups: ChromeCommandGroup::ALL + .iter() + .map(|group| (*group, command_ids(&[group_commands(*group)]))) + .collect(), + devtools: command_ids(&[DEVTOOLS_COMMANDS]), + zoom: command_ids(&[ZOOM_COMMANDS]), + }) +} + +/// The numeric ids of `groups` in the running CEF build. +/// +/// A name this build does not know resolves to -1 and is dropped, so an IDC name +/// retired by a later Chromium stops being blocked rather than blocking whatever +/// command -1 happens to reach. +fn command_ids(groups: &[&[&CStr]]) -> Vec { + groups + .iter() + .flat_map(|names| names.iter()) + .map(|name| unsafe { cef::sys::cef_id_for_command_id_name(name.as_ptr()) }) + .filter(|id| *id != -1) + .collect() +} + +wrap_with_args! { + wrap_command_handler => TauriCefCommandHandlerArgs; + + pub struct TauriCefCommandHandler { + devtools_enabled: bool, + zoom_hotkeys_enabled: bool, + allowed_chrome_commands: Vec, + frame_navigation_state: crate::FrameNavigationState, + } + + impl CommandHandler { + fn on_chrome_command( + &self, + browser: Option<&mut Browser>, + command_id: ::std::os::raw::c_int, + _disposition: WindowOpenDisposition, + ) -> ::std::os::raw::c_int { + // Scoped the way the display handler and the frame observer are: CEF routes + // browsers this webview does not own through this very client. A DevTools + // window is the standing case — `ChromeBrowserDelegate` reuses the opener's + // client when F12 or the context menu's Inspect opens one — and it is a real + // Chrome window whose zoom, print and find accelerators are its own to run. + // + // The identity is bound by the frame observer, which the root client always + // installs, so it is recorded on this browser's first frame notification, + // long before an accelerator can reach it. + if !self.owns(browser) { + return 0; + } + + if self.blocks(command_id) { 1 } else { 0 } + } + + // The four predicates below are pinned to what CEF would do on its own, with + // one exception. `wrap_command_handler!` installs every callback of the + // handler, and the trait's own default body returns 0 — "hide" and "disable" — + // so *not* overriding them would silently strip Chrome UI rather than leave it + // alone. An app window has no Chrome UI for these to act on, but a CEF-owned + // popup is a real Chrome window whose location bar is worth keeping: it tells + // the user which site an SSO or OAuth page belongs to. + fn is_chrome_app_menu_item_visible( + &self, + _browser: Option<&mut Browser>, + _command_id: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int { + 1 + } + + /// The exception: a command this handler swallows is reported disabled rather + /// than left enabled and then ignored. + /// + /// Only a CEF-owned popup ever consults this, through + /// `AppMenuModel::IsCommandIdEnabled`; an app window has no app menu. It keeps + /// a blocked entry from drawing as a working one, and satisfies the `DCHECK` + /// Chromium makes that a command it dispatched was enabled. + /// + /// This does not gate the accelerator table or `HandleCommand`, so it cannot + /// affect what `on_chrome_command` above swallows; and the scoping is the same, + /// so a DevTools window keeps every entry of its own app menu. + fn is_chrome_app_menu_item_enabled( + &self, + browser: Option<&mut Browser>, + command_id: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int { + // Answering 1 for everything else is load-bearing: the trait's own body + // returns 0, so an unanswered command would be disabled. + if self.owns(browser) && self.blocks(command_id) { 0 } else { 1 } + } + + fn is_chrome_page_action_icon_visible( + &self, + _icon_type: ChromePageActionIconType, + ) -> ::std::os::raw::c_int { + 1 + } + + fn is_chrome_toolbar_button_visible( + &self, + _button_type: ChromeToolbarButtonType, + ) -> ::std::os::raw::c_int { + 1 + } + } +} + +impl TauriCefCommandHandler { + /// Whether `browser` is the native browser this client's webview owns. + /// + /// CEF routes browsers this webview does not own through this very client — a + /// DevTools window opened on it is the standing case — and their commands are + /// theirs to run. + /// + /// Answers `false` when the identity cannot be established, so a browser this + /// webview may not own never has its commands swallowed. That makes the blocking + /// best-effort rather than a security boundary; what must not be reachable (the + /// renderer sandbox, the command line lockdown, DevTools when the webview + /// disabled them) is enforced elsewhere. + fn owns(&self, browser: Option<&mut Browser>) -> bool { + browser + .map(|browser| { + self + .frame_navigation_state + .has_browser_id(browser.identifier()) + }) + .unwrap_or(false) + } + + /// Whether this webview swallows `command_id`. + /// + /// Anything not named in the tables above runs unchanged: clipboard, find in + /// page, text selection, undo and redo, fullscreen and reload are all things an + /// app window legitimately uses. A group the webview named in + /// `allowed_chrome_commands` is skipped entirely, so its commands run the way + /// they would in a browser. + fn blocks(&self, command_id: c_int) -> bool { + let commands = blocked_commands(); + commands + .groups + .iter() + .filter(|(group, _)| !self.allowed_chrome_commands.contains(group)) + .any(|(_, ids)| ids.contains(&command_id)) + || (!self.devtools_enabled && commands.devtools.contains(&command_id)) + || (!self.zoom_hotkeys_enabled && commands.zoom.contains(&command_id)) + } +} diff --git a/src/cef_impl/client/context_menu.rs b/src/cef_impl/client/context_menu.rs index 0128696..3335e85 100644 --- a/src/cef_impl/client/context_menu.rs +++ b/src/cef_impl/client/context_menu.rs @@ -2,7 +2,177 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use cef::*; +//! Context menu policy for the webview. +//! +//! The runtime creates Chrome style browsers, so the model handed to +//! `on_before_context_menu` is Chrome's own page context menu — the same one a +//! browser tab shows, with back/forward/reload, save as, print, translate, view +//! page source, "Search the web for…", open link in a new tab/window/incognito +//! window and Inspect. Alloy style would have handed us a small menu addressed +//! by the `MENU_ID_*` constants; a Chrome style model carries IDC command ids +//! instead, so those constants are useless here and entries have to be matched +//! by IDC id. +//! +//! This file filters that model down to the entries that mean something inside +//! an application window. Removing by command id is exact, is a no-op when the +//! entry is not in this particular menu, and does not care what order Chrome +//! lays the menu out in. + +use std::{ffi::CStr, os::raw::c_int, sync::OnceLock}; + +use cef::{ + resources, + sys::{cef_id_for_command_id_name, cef_menu_item_type_t}, + *, +}; + +/// Entries that navigate, print, save, or hand the page to a web service. None +/// of them belong in an application window, and most of them lead somewhere the +/// app has no control over. +/// +/// Everything not listed here is kept, which covers what an app genuinely +/// wants: undo/redo, cut/copy/paste (including paste as plain text), delete, +/// select all, the spellcheck suggestions and add-to-dictionary, emoji, and the +/// copy-link-address / copy-image family. +const BROWSER_ONLY_COMMANDS: &[&CStr] = &[ + // Navigation and page lifecycle. + resources::IDC_BACK, + resources::IDC_FORWARD, + resources::IDC_RELOAD, + resources::IDC_RELOAD_BYPASSING_CACHE, + resources::IDC_RELOAD_CLEARING_CACHE, + resources::IDC_CONTENT_CONTEXT_RELOADFRAME, + // Saving, printing, and looking behind the page. + resources::IDC_SAVE_PAGE, + resources::IDC_PRINT, + resources::IDC_BASIC_PRINT, + resources::IDC_VIEW_SOURCE, + resources::IDC_CONTENT_CONTEXT_VIEWFRAMESOURCE, + resources::IDC_CONTENT_CONTEXT_VIEWPAGEINFO, + resources::IDC_CONTENT_CONTEXT_VIEWFRAMEINFO, + resources::IDC_CONTENT_CONTEXT_SAVELINKAS, + resources::IDC_CONTENT_CONTEXT_SAVEIMAGEAS, + resources::IDC_CONTENT_CONTEXT_SAVEAVAS, + resources::IDC_CONTENT_CONTEXT_SAVEPLUGINAS, + resources::IDC_CONTENT_CONTEXT_SAVEVIDEOFRAMEAS, + // Opening a browser surface the app does not own. + resources::IDC_CONTENT_CONTEXT_OPENLINKNEWTAB, + resources::IDC_CONTENT_CONTEXT_OPENLINKNEWWINDOW, + resources::IDC_CONTENT_CONTEXT_OPENLINKOFFTHERECORD, + resources::IDC_CONTENT_CONTEXT_OPENLINKINPROFILE, + resources::IDC_CONTENT_CONTEXT_OPENLINKWITH, + resources::IDC_CONTENT_CONTEXT_OPENLINKBOOKMARKAPP, + resources::IDC_CONTENT_CONTEXT_OPENLINKSPLITVIEW, + resources::IDC_CONTENT_CONTEXT_OPENIMAGENEWTAB, + resources::IDC_CONTENT_CONTEXT_OPEN_ORIGINAL_IMAGE_NEW_TAB, + resources::IDC_CONTENT_CONTEXT_OPENAVNEWTAB, + resources::IDC_CONTENT_CONTEXT_GOTOURL, + resources::IDC_CONTENT_CONTEXT_OPEN_IN_READING_MODE, + resources::IDC_CONTENT_CONTEXT_ADD_LINK_TO_READING_LIST, + // Sending the page, a selection, or a frame off to a web service. + resources::IDC_CONTENT_CONTEXT_TRANSLATE, + resources::IDC_CONTENT_CONTEXT_PARTIAL_TRANSLATE, + resources::IDC_CONTENT_CONTEXT_SEARCHWEBFOR, + resources::IDC_CONTENT_CONTEXT_SEARCHWEBFORNEWTAB, + resources::IDC_CONTENT_CONTEXT_SEARCHWEBFORIMAGE, + resources::IDC_CONTENT_CONTEXT_SEARCHWEBFORVIDEOFRAME, + resources::IDC_CONTENT_CONTEXT_SEARCHLENSFORIMAGE, + resources::IDC_CONTENT_CONTEXT_SEARCHLENSFORVIDEOFRAME, + resources::IDC_CONTENT_CONTEXT_LENS_OVERLAY, + resources::IDC_CONTENT_CONTEXT_LENS_REGION_SEARCH, + resources::IDC_CONTENT_CONTEXT_WEB_REGION_SEARCH, + resources::IDC_CONTENT_CONTEXT_INSPECTELEMENT_WITH_GEMINI, + resources::IDC_CONTENT_CONTEXT_SHARING_SUBMENU, + resources::IDC_CONTENT_CONTEXT_GENERATE_QR_CODE, + resources::IDC_ROUTE_MEDIA, +]; + +/// Entries that open DevTools. Kept when the webview enables devtools, removed +/// otherwise. +const DEVTOOLS_COMMANDS: &[&CStr] = &[ + resources::IDC_CONTENT_CONTEXT_INSPECTELEMENT, + resources::IDC_CONTENT_CONTEXT_INSPECTELEMENT_WITH_DEVTOOLS, + resources::IDC_CONTENT_CONTEXT_INSPECTBACKGROUNDPAGE, + resources::IDC_DEV_TOOLS, + resources::IDC_DEV_TOOLS_INSPECT, + resources::IDC_DEV_TOOLS_CONSOLE, + resources::IDC_DEV_TOOLS_DEVICES, + resources::IDC_DEV_TOOLS_TOGGLE, +]; + +/// What [`cef_id_for_command_id_name`] answers for an IDC name the running CEF +/// build does not know, and also what CEF reports as the command id of an entry +/// that has none (a separator, or an out of range index). An unresolved name +/// must therefore never reach `remove`, or it would delete an arbitrary entry — +/// [`resolve_command_ids`] drops these instead. +const UNKNOWN_COMMAND_ID: c_int = -1; + +struct CommandIds { + browser_only: Vec, + devtools: Vec, +} + +/// The IDC names above, resolved to the numeric command ids of the running CEF +/// build. +/// +/// The mapping is build specific but fixed for the life of the process, so it is +/// resolved once rather than on every right click — this runs on the UI thread +/// while the user waits for the menu. Resolving lazily also keeps the lookups +/// after CEF initialization. +fn command_ids() -> &'static CommandIds { + static COMMAND_IDS: OnceLock = OnceLock::new(); + + COMMAND_IDS.get_or_init(|| CommandIds { + browser_only: resolve_command_ids(BROWSER_ONLY_COMMANDS), + devtools: resolve_command_ids(DEVTOOLS_COMMANDS), + }) +} + +fn resolve_command_ids(names: &[&CStr]) -> Vec { + names + .iter() + // SAFETY: the pointer comes from a `&'static CStr`, so it is a valid NUL + // terminated string that outlives the call. + .map(|name| unsafe { cef_id_for_command_id_name(name.as_ptr()) }) + .filter(|id| *id != UNKNOWN_COMMAND_ID) + .collect() +} + +/// Drops the separators the removals leave behind: a menu must not open or end +/// with one, and two in a row draw as a double rule. +/// +/// Every loop here advances on a failed `remove_at`. A removal CEF refuses does +/// not shrink `count()`, so retrying it would spin on CEF's UI thread, which this +/// runtime drives with an external message pump — hanging the whole application +/// rather than just the menu. +fn remove_redundant_separators(model: &MenuModel) { + let separator = MenuItemType::from(cef_menu_item_type_t::MENUITEMTYPE_SEPARATOR); + let is_separator = |index: usize| model.type_at(index) == separator; + let removed = |result: c_int| result != 0; + + // Starting as if a separator had just been seen also drops the leading ones. + let mut previous_was_separator = true; + let mut index = 0; + while index < model.count() { + if is_separator(index) { + if previous_was_separator && removed(model.remove_at(index)) { + // The entries after `index` shifted down into it, so the same index is + // the next entry to look at. Only a removal that actually happened may + // hold the index still. + continue; + } + previous_was_separator = true; + } else { + previous_was_separator = false; + } + index += 1; + } + + while model.count() > 0 + && is_separator(model.count() - 1) + && removed(model.remove_at(model.count() - 1)) + {} +} wrap_context_menu_handler! { pub struct TauriCefContextMenuHandler { @@ -17,11 +187,27 @@ wrap_context_menu_handler! { _params: Option<&mut ContextMenuParams>, model: Option<&mut MenuModel>, ) { - if !self.devtools_enabled - && let Some(model) = model - { - model.remove_at(model.count() - 1); + let Some(model) = model else { + return; + }; + + // Removing a command this menu does not carry is a no-op, so the whole + // policy can be applied to every menu without first asking `params` which + // kind of menu it is. + let command_ids = command_ids(); + for id in &command_ids.browser_only { + model.remove(*id); + } + if !self.devtools_enabled { + for id in &command_ids.devtools { + model.remove(*id); + } } + + remove_redundant_separators(model); + + // An empty model is left empty on purpose: CEF then shows no menu at all, + // which is the right outcome for a menu with nothing in it. } } } diff --git a/src/cef_impl/client/display.rs b/src/cef_impl/client/display.rs index 0e4aa7c..423e0c7 100644 --- a/src/cef_impl/client/display.rs +++ b/src/cef_impl/client/display.rs @@ -6,12 +6,17 @@ use std::sync::Arc; use cef::*; +use crate::macros::wrap_with_args; use crate::webview::INITIAL_LOAD_URL; -wrap_display_handler! { +wrap_with_args! { + wrap_display_handler => TauriCefDisplayHandlerArgs; + pub struct TauriCefDisplayHandler { - document_title_changed_handler: Option>, - address_changed_handler: Option>, + document_title_changed_handler: Option>, + frame_event_handler: Option>, + console_message_handler: Option>, + frame_navigation_state: crate::FrameNavigationState, } impl DisplayHandler { @@ -32,19 +37,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,8 +51,37 @@ 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 }, + ); } } + + fn on_console_message( + &self, + browser: Option<&mut Browser>, + level: LogSeverity, + message: Option<&CefString>, + source: Option<&CefString>, + line: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int { + if let Some(handler) = &self.console_message_handler { + // Scoped the way the frame observer is: CEF routes browsers this webview + // does not own through this very client — a DevTools window is the + // standing case, and its frontend is itself a page that logs. + let observed = browser + .map(|browser| self.frame_navigation_state.has_browser_id(browser.identifier())) + .unwrap_or(false); + if observed { + handler(crate::ConsoleMessage::from_cef(level, message, source, line)); + } + } + + // 0 leaves CEF's own logging of the message exactly as it was. + 0 + } } } 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..1b91d36 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,61 +10,74 @@ 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 command; mod context_menu; mod display; mod download; mod drag; +mod frame; mod keyboard; mod life_span; mod load; mod permission; mod process; +use command::{TauriCefCommandHandler, TauriCefCommandHandlerArgs}; use context_menu::TauriCefContextMenuHandler; -use display::TauriCefDisplayHandler; +use display::{TauriCefDisplayHandler, TauriCefDisplayHandlerArgs}; 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; +pub(crate) use permission::PermissionRequestHandler; 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) console_message_handler: Option>, + pub(crate) permission_request_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(), + console_message_handler: self.console_message_handler.clone(), + permission_request_handler: self.permission_request_handler.clone(), web_content_process_terminate_handler: self.web_content_process_terminate_handler.clone(), } } } -wrap_client! { +wrap_with_args! { + wrap_client => TauriCefBrowserClientArgs; + pub(crate) struct TauriCefBrowserClient { pub(crate) context: RuntimeContext, pub(crate) window_id: WindowId, @@ -72,15 +85,26 @@ wrap_client! { pub(crate) label: String, initial_url: Option, devtools_enabled: bool, + zoom_hotkeys_enabled: bool, + allowed_chrome_commands: Vec, 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,42 +112,127 @@ 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(), + certificate_errors: self.context.certificate_errors, + }, )) } 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 zoom_hotkeys_enabled = self.zoom_hotkeys_enabled; + // A CEF-owned popup is a real Chrome window, not an app window, so it keeps the + // opener's allowances rather than being locked down harder than its opener. + let allowed_chrome_commands = self.allowed_chrome_commands.clone(); + 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 permission_request_handler = self.handlers.permission_request_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, + zoom_hotkeys_enabled, + allowed_chrome_commands: allowed_chrome_commands.clone(), + 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(), + // The opener's refusals carry over, its grants do not. A popup shows + // content the opener navigated to — an SSO or OAuth window is the + // standing case — and a `PermissionKind` names no origin, so an + // `Allow` the app gave for its own content is no answer about that + // other content; CEF's own prompt asks the user instead. A `Deny` + // carries over so a refused permission cannot be obtained by opening + // a popup. + permission_request_handler: permission_request_handler.clone().map(|handler| { + Arc::new(move |kind| match handler(kind) { + tauri_runtime::webview::PermissionResponse::Allow => { + tauri_runtime::webview::PermissionResponse::Default + } + response => response, + }) as Arc + }), + // A `ConsoleMessage` carries the source URL of whatever logged it, so + // routing a popup's output to the opener's observer would report an + // SSO or OAuth window's URLs to an observer registered for the app's + // own content. + console_message_handler: None, + 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(), - )) + Some(TauriCefDisplayHandler::build(TauriCefDisplayHandlerArgs { + document_title_changed_handler: self.handlers.document_title_changed_handler.clone(), + frame_event_handler: self.handlers.frame_event_handler.clone(), + console_message_handler: self.handlers.console_message_handler.clone(), + frame_navigation_state: self.frame_navigation_state.clone(), + })) } fn download_handler(&self) -> Option { @@ -142,17 +251,30 @@ wrap_client! { Some(TauriCefKeyboardHandler::new(self.devtools_enabled)) } + fn command_handler(&self) -> Option { + Some(TauriCefCommandHandler::build(TauriCefCommandHandlerArgs { + devtools_enabled: self.devtools_enabled, + zoom_hotkeys_enabled: self.zoom_hotkeys_enabled, + allowed_chrome_commands: self.allowed_chrome_commands.clone(), + frame_navigation_state: self.frame_navigation_state.clone(), + })) + } + fn permission_handler(&self) -> Option { - Some(TauriCefPermissionHandler::new(self.label.clone())) + Some(TauriCefPermissionHandler::new( + self.handlers.permission_request_handler.clone(), + )) } 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..f3d4897 100644 --- a/src/cef_impl/client/permission.rs +++ b/src/cef_impl/client/permission.rs @@ -2,199 +2,474 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -//! Adapter from CEF's permission prompts to the runtime-neutral policy in -//! [`crate::policy`]. +//! The application's permission policy, as CEF asks for it. //! -//! Media-access requests use the same policy as Chromium permission prompts. +//! CEF has two entry points and they behave very differently once answered. The +//! user-facing half of what follows is documented on +//! [`CefWebviewAttributes`](crate::CefWebviewAttributes). //! -//! 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. +//! # The prompt path answers once per origin, forever +//! +//! `on_show_permission_prompt` is reached only while Chromium's stored content +//! setting for that (origin, permission) still says "ask", and `cont` persists the +//! answer into the on-disk profile: `ACCEPT` through +//! `PermissionRequestManager::Accept()`, the path a click on Chrome's Allow button +//! takes, and `DENY` through `Deny()`, which stores BLOCK. Either way Chromium +//! never asks again, so a handler whose answer depends on application state is +//! ignored from its second request onwards, including across restarts. +//! +//! There is no callback for "the app changed its mind". An app that has to revoke +//! a grant, or undo a refusal, rewrites the content setting itself: +//! `Webview::browser()` reaches the `cef::Browser`, and from it +//! `host().request_context().set_content_setting(...)`. +//! +//! # The media path answers every call +//! +//! Chromium routes every `getUserMedia()` call through +//! `on_request_media_access_permission`, so camera and microphone requests do +//! reach the handler each time and a changing answer is honored. +//! `MediaAccessCallback` persists nothing, which is why this path — and only this +//! path — writes the content settings itself (see [`allow_content_settings`]). +//! +//! # Unmapped request types are [`PermissionKind::Other`] +//! +//! [`PERMISSION_KINDS`] is a partial map: Chromium has more request types than +//! Tauri has kinds, and everything left over — storage access, FedCM, protocol +//! handler registration, idle detection, local and loopback network access, web +//! app installation, the WebXR sessions, hand tracking, keyboard lock and disk +//! quota — arrives as `PermissionKind::Other`, as does any request type a future +//! CEF build adds. +//! +//! Failing closed is deliberate, but it means a handler written for another +//! platform as `match kind { Camera => Allow, _ => Deny }` hard-denies all of +//! them, and denying storage access or FedCM breaks third-party SSO flows +//! outright. A handler that only means to answer about the kinds it names should +//! return [`PermissionResponse::Default`] for the rest. + +use std::sync::Arc; + +use cef::sys::cef_media_access_permission_types_t as MediaPermissionType; +use cef::sys::cef_permission_request_types_t as PermissionType; +use cef::*; +use tauri_runtime::webview::{PermissionKind, PermissionResponse}; -use cef::{rc::Rc as _, *}; +/// The application's answer to a permission request, as +/// `PendingWebview::permission_request_handler` carries it. +/// +/// `tauri_runtime::webview` declares the same alias but keeps it private, so it is +/// redeclared here; both name one and the same type. +pub(crate) type PermissionRequestHandler = + dyn Fn(PermissionKind) -> PermissionResponse + Send + Sync; -use crate::policy::{self, PermissionKind, RequestSource}; +const AUDIO_CAPTURE: u32 = MediaPermissionType::CEF_MEDIA_PERMISSION_DEVICE_AUDIO_CAPTURE as u32; +const VIDEO_CAPTURE: u32 = MediaPermissionType::CEF_MEDIA_PERMISSION_DEVICE_VIDEO_CAPTURE as u32; +const DESKTOP_AUDIO_CAPTURE: u32 = + MediaPermissionType::CEF_MEDIA_PERMISSION_DESKTOP_AUDIO_CAPTURE as u32; +const DESKTOP_VIDEO_CAPTURE: u32 = + MediaPermissionType::CEF_MEDIA_PERMISSION_DESKTOP_VIDEO_CAPTURE as u32; + +/// The bits a `getDisplayMedia()` request sets. Never granted from here; see +/// [`grants_desktop_capture`]. +const DESKTOP_CAPTURE: u32 = DESKTOP_AUDIO_CAPTURE | DESKTOP_VIDEO_CAPTURE; + +/// Media capture permissions granted to Alloy style browsers. Desktop capture is +/// deliberately excluded. +const ALLOY_MEDIA_PERMISSIONS: u32 = AUDIO_CAPTURE | VIDEO_CAPTURE; + +/// The [`PermissionKind`] the application is asked about for each permission request +/// type. +/// +/// Chromium's request types are finer grained than Tauri's kinds — the plain camera +/// stream and its pan-tilt-zoom control are both `Camera` — and most of them have no +/// Tauri counterpart at all. A request type absent from this table is reported as +/// [`PermissionKind::Other`]; see the module docs. +/// +/// [`PermissionKind::Autoplay`] has no entry: Chromium gates autoplay through its +/// media engagement policy rather than through a permission request, so no CEF +/// request carries it. +const PERMISSION_KINDS: &[(u32, PermissionKind)] = &[ + ( + PermissionType::CEF_PERMISSION_TYPE_CAMERA_PAN_TILT_ZOOM as u32, + PermissionKind::Camera, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_CAMERA_STREAM as u32, + PermissionKind::Camera, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_MIC_STREAM as u32, + PermissionKind::Microphone, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_CAPTURED_SURFACE_CONTROL as u32, + PermissionKind::DisplayCapture, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_GEOLOCATION as u32, + PermissionKind::Geolocation, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_NOTIFICATIONS as u32, + PermissionKind::Notifications, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_CLIPBOARD as u32, + PermissionKind::ClipboardRead, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_MIDI_SYSEX as u32, + PermissionKind::Midi, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_SENSORS as u32, + PermissionKind::Sensors, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_LOCAL_FONTS as u32, + PermissionKind::LocalFonts, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_WINDOW_MANAGEMENT as u32, + PermissionKind::WindowManagement, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_POINTER_LOCK as u32, + PermissionKind::PointerLock, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_MULTIPLE_DOWNLOADS as u32, + PermissionKind::AutomaticDownloads, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_FILE_SYSTEM_ACCESS as u32, + PermissionKind::FileSystemAccess, + ), + ( + PermissionType::CEF_PERMISSION_TYPE_PROTECTED_MEDIA_IDENTIFIER as u32, + PermissionKind::MediaKeySystemAccess, + ), +]; + +/// The [`PermissionKind`] the application is asked about for each media access type. +/// +/// Both desktop capture types are one kind: `getDisplayMedia` is a single Tauri +/// permission whether the page asks for the screen's video, its audio, or both. +const MEDIA_PERMISSION_KINDS: &[(u32, PermissionKind)] = &[ + (AUDIO_CAPTURE, PermissionKind::Microphone), + (VIDEO_CAPTURE, PermissionKind::Camera), + (DESKTOP_AUDIO_CAPTURE, PermissionKind::DisplayCapture), + (DESKTOP_VIDEO_CAPTURE, PermissionKind::DisplayCapture), +]; + +/// How the application answered a whole CEF permission request. +/// +/// CEF asks about a bitmask of types and is answered once for all of them, so the +/// per-kind answers have to be combined. [`Self::Denied`] wins over everything, +/// because a refusal must never end up granting the rest of the request, and a +/// request is granted only when the application allowed every type in it. Anything +/// else leaves part of the request unanswered, and the platform default runs for it +/// unchanged. +enum AppDecision { + /// The application denied at least one of the requested permissions. + Denied, + /// The application allowed every requested permission. + Allowed, + /// The application left at least one requested permission to the platform, and + /// denied none. Also the answer when the webview has no permission handler. + NoOpinion, +} wrap_permission_handler! { pub struct TauriCefPermissionHandler { - webview_label: String, + permission_request_handler: Option>, } 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; - - 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 host = browser_host(browser); + + match self.decide(requested_permissions, media_permission_kind) { + // Answer for the application, whatever the runtime style: neither Chrome's + // prompt nor Alloy's blanket grant may override an explicit answer. + AppDecision::Denied => { + let Some(callback) = callback else { + return 0; + }; + callback.cont(0); + 1 + } + AppDecision::Allowed => { + // An `Allow` grants the camera and the microphone, but never the desktop: + // no `PermissionKind` answer can name what a screen share would expose, + // so that choice stays with CEF's picker. See `grants_desktop_capture`. + if grants_desktop_capture(requested_permissions) { + return 0; + } + + let Some(callback) = callback else { + return 0; + }; + allow_content_settings( + host.as_ref(), + requesting_origin, + &media_content_settings(requested_permissions), + ); + callback.cont(requested_permissions); + 1 + } + AppDecision::NoOpinion => { + // 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. + if !is_alloy_style(host.as_ref()) { + return 0; + } + + // Alloy style has no permission UI and its default handling denies the request, + // so grant camera and microphone capture here. Desktop capture is left to that + // default handling, which refuses it — see `grants_desktop_capture`. + if grants_desktop_capture(requested_permissions) { + return 0; + } + + let Some(callback) = callback else { + return 0; + }; - let grant_recorder = GrantRecorder { - request_context, - origin: origin.clone(), - content_types, - }; - - policy::dispatch( - &self.webview_label, - &origin, - RequestSource::MediaAccess, - kinds, - is_main_frame, - move |granted| { - if granted { - grant_recorder.record(); + let allowed = requested_permissions & ALLOY_MEDIA_PERMISSIONS; + if allowed == 0 { + return 0; } - callback.cont(if granted { - requested_permissions - } else { - bits::CEF_MEDIA_PERMISSION_NONE as u32 - }); - }, - ); - 1 + + allow_content_settings( + host.as_ref(), + requesting_origin, + &media_content_settings(allowed), + ); + + 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>, + _requesting_origin: Option<&CefString>, requested_permissions: u32, callback: Option<&mut PermissionPromptCallback>, ) -> ::std::os::raw::c_int { - 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 + let host = browser_host(browser); + + match self.decide(requested_permissions, permission_kind) { + // Answer for the application, whatever the runtime style, and without + // showing Chrome's prompt: the application already decided. + AppDecision::Denied => { + let Some(callback) = callback else { + return 0; }; - callback.cont(PermissionRequestResult::from(result)); - }, - ); - 1 + callback.cont(PermissionRequestResult::DENY); + 1 + } + AppDecision::Allowed => { + let Some(callback) = callback else { + return 0; + }; + // No content setting is written here: `cont(ACCEPT)` reaches + // `PermissionRequestManager::Accept()`, which persists the grant itself. + // Writing one on top would also be over-broad — it names no top-level + // URL, so a storage-access grant Chromium scopes to an (embedded origin, + // top-level site) pair would end up granted on every site. + callback.cont(PermissionRequestResult::ACCEPT); + 1 + } + AppDecision::NoOpinion => { + // Chrome style displays the permission prompt UI. + if !is_alloy_style(host.as_ref()) { + 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; + }; + + // As above: accepting is what persists the grant. + 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() +impl TauriCefPermissionHandler { + /// Asks the application about every type set in `requested` and combines the + /// answers, as [`AppDecision`] describes. + /// + /// `kind` names the [`PermissionKind`] of one request type; the two CEF request + /// bitmasks number their types differently, so each entry point passes its own. + fn decide(&self, requested: u32, kind: fn(u32) -> PermissionKind) -> AppDecision { + let Some(handler) = &self.permission_request_handler else { + return AppDecision::NoOpinion; + }; + + // A refusal short-circuits: nothing after it could weaken it, so the types past + // it are not asked about. A handler that logs or keeps state therefore sees + // only a prefix of a denied request. That is deliberate — the handler is a + // policy predicate, not an event feed. + let mut asked = false; + let mut allowed_all = true; + for permission in requested_permissions(requested) { + asked = true; + match handler(kind(permission)) { + PermissionResponse::Deny => return AppDecision::Denied, + PermissionResponse::Allow => {} + PermissionResponse::Default => allowed_all = false, + } + } + + if asked && allowed_all { + AppDecision::Allowed + } else { + AppDecision::NoOpinion + } + } } -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 +/// Whether answering `requested` through [`MediaAccessCallback::cont`] would hand +/// the page a desktop stream, which is never this handler's to give. +/// +/// CEF builds the granted stream straight from the mask: a set +/// `DESKTOP_VIDEO_CAPTURE` bit with no requested device id synthesises a +/// `DesktopMediaID(TYPE_SCREEN, kFullDesktopScreenId)` and returns it, so +/// `getDisplayMedia()` resolves with the whole desktop and *no picker at all*. A +/// [`PermissionKind`] names no screen, window or tab, so an app writing +/// `.on_permission_request(|_| PermissionResponse::Allow)` would silently give any +/// page in the webview a full-desktop stream. Deferring to CEF keeps its picker in +/// front of the user, which is the only thing that can name what is shared. +/// +/// The whole request is deferred, never a part of it: CEF requires +/// `allowed_permissions` to equal `required_permissions` for a request carrying the +/// device capture bits, so granting the device half of a mixed mask while +/// withholding the desktop half is not a legal answer. +/// +/// A `Deny` is unaffected: `cont(0)` refuses every bit in the request, desktop +/// capture included. +fn grants_desktop_capture(requested: u32) -> bool { + requested & DESKTOP_CAPTURE != 0 } -/// `SetContentSetting` is UI-thread only; the policy may answer from any thread. -struct GrantRecorder { - request_context: Option, - origin: String, - content_types: Vec, +/// The individual permission types set in a CEF request bitmask. +fn requested_permissions(requested: u32) -> impl Iterator { + (0..u32::BITS) + .map(move |bit| requested & (1 << bit)) + .filter(|permission| *permission != 0) } -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; - } +/// The [`PermissionKind`] of one `cef_permission_request_types_t` value. +fn permission_kind(permission: u32) -> PermissionKind { + lookup_permission_kind(PERMISSION_KINDS, permission) +} - if cef::currently_on(cef::sys::cef_thread_id_t::TID_UI.into()) != 0 { - record_grant(request_context, &self.origin, &self.content_types); - return; - } +/// The [`PermissionKind`] of one `cef_media_access_permission_types_t` value. +fn media_permission_kind(permission: u32) -> PermissionKind { + lookup_permission_kind(MEDIA_PERMISSION_KINDS, permission) +} - 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 lookup_permission_kind(table: &[(u32, PermissionKind)], permission: u32) -> PermissionKind { + table + .iter() + .find(|(candidate, _)| *candidate == permission) + .map(|(_, kind)| *kind) + .unwrap_or(PermissionKind::Other) } -fn record_grant( - request_context: &RequestContext, - origin: &str, - content_types: &[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, - ); +/// The content settings recording granted media capture. +/// +/// Desktop capture has none: `getDisplayMedia` is gated by Chromium's source +/// picker rather than by a content setting, so there is nothing to record for it. +fn media_content_settings(granted: u32) -> Vec { + let mut settings = Vec::with_capacity(2); + if granted & AUDIO_CAPTURE != 0 { + settings.push(ContentSettingTypes::MEDIASTREAM_MIC); } + if granted & VIDEO_CAPTURE != 0 { + settings.push(ContentSettingTypes::MEDIASTREAM_CAMERA); + } + settings +} + +/// The host of `browser`, when CEF hands one out. +fn browser_host(browser: Option<&mut Browser>) -> Option { + browser.and_then(|browser| browser.host()) +} + +/// Whether `host` uses the Alloy runtime style, which provides no permission UI. +/// +/// A browser whose host CEF did not hand out reports `false`, so that permission +/// handling is deferred to CEF, which is correct for the Chrome style Tauri +/// webviews use by default. +fn is_alloy_style(host: Option<&BrowserHost>) -> bool { + host.is_some_and(|host| host.runtime_style() == RuntimeStyle::ALLOY) } -wrap_task! { - struct RecordGrantTask { - request_context: RequestContext, - origin: String, - content_types: Vec, +/// Records granted media capture as content settings for `requesting_origin`. +/// +/// `MediaAccessCallback::cont` grants the stream and nothing else: the grant is +/// invisible to Chromium's permission layer, so `navigator.permissions.query()` +/// keeps reporting `prompt` and `enumerateDevices()` keeps returning a redacted +/// list even though `getUserMedia` works. Writing the content setting is what +/// Chrome style does when the user accepts its prompt. +/// +/// The permission *prompt* path must not use this: `cont(ACCEPT)` already persists +/// the grant, and the secondary pattern below is a wildcard — harmless for the two +/// media settings, which Chromium scopes to the requesting origin alone, but wrong +/// for anything it scopes to an (origin, top-level site) pair. +/// +/// # The setting outlives the answer that wrote it +/// +/// The media path is consulted on every `getUserMedia()` call, so a handler may +/// allow once and deny afterwards. Denying still refuses the stream, but the +/// content setting stays ALLOW, so `navigator.permissions.query()` keeps reporting +/// `granted` and `enumerateDevices()` keeps returning unredacted device labels for +/// that origin. An application that revokes camera or microphone access for good +/// should rewrite the setting itself; see the module docs. +/// +/// 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, and nothing when CEF +/// handed out no host, because the request context is reached through it. +fn allow_content_settings( + host: Option<&BrowserHost>, + requesting_origin: Option<&CefString>, + settings: &[ContentSettingTypes], +) { + if requesting_origin.is_none() || settings.is_empty() { + return; } - impl Task { - fn execute(&self) { - record_grant(&self.request_context, &self.origin, &self.content_types); - } + let Some(context) = host.and_then(|host| host.request_context()) else { + return; + }; + + 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/mod.rs b/src/cef_impl/mod.rs index 9c9e861..880b1d7 100644 --- a/src/cef_impl/mod.rs +++ b/src/cef_impl/mod.rs @@ -5,5 +5,6 @@ pub(crate) mod client; pub(crate) mod cookie; pub(crate) mod ipc; +pub(crate) mod preferences; pub(crate) mod request_context; pub(crate) mod request_handler; diff --git a/src/cef_impl/preferences.rs b/src/cef_impl/preferences.rs new file mode 100644 index 0000000..6ec5bac --- /dev/null +++ b/src/cef_impl/preferences.rs @@ -0,0 +1,296 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Chromium preferences and content settings, applied to every webview's request context +//! and to the browser-wide preference store. +//! +//! The CEF runtime creates Chrome style browsers, so each webview is backed by a real +//! Chrome profile and inherits the browser-shaped behaviour that comes with it: a "Save +//! password?" bubble on any form submit, address and credit-card save bubbles, a +//! "Translate this page?" bubble, and a handful of background requests to Google. None of +//! that belongs in an application webview, so we turn it off per request context right +//! after the profile finishes initializing. +//! +//! # Two stores, two lifetimes +//! +//! A *profile* preference lives on the request context and can only be written once that +//! context's underlying Chromium `Profile` exists — which is what +//! `on_request_context_initialized` signals. A *global* preference lives in Chromium's +//! local state and is reachable through `cef::preference_manager_get_global()` once the +//! CEF context is initialized. `devtools.availability` is the first kind; +//! `devtools.remote_debugging.allowed` is the second. +//! +//! # Safe Browsing stays on +//! +//! [`PREFERENCES`] deliberately leaves `safebrowsing.enabled` alone. A Tauri webview +//! routinely loads content the developer does not control - OAuth and SSO flows, embedded +//! third-party pages, iframes, and the popups this runtime supports - so it is not the +//! closed world that would make the protection pointless, and standard protection is a +//! local hash-prefix database rather than a per-navigation callback to Google. +//! +//! An application whose webview only ever loads its own content can still opt out with +//! `Cef::safe_browsing(false)`, which is also how any entry in [`PREFERENCES`] is turned +//! back on. + +use cef::{ + CefString, ContentSettingTypes, ContentSettingValues, ImplDictionaryValue, ImplListValue, + ImplPreferenceManager, ImplRequestContext, ImplValue, RequestContext, Value, +}; + +/// Builds the `error` out-parameter that every +/// [`ImplPreferenceManager::set_preference`] call has to pass. +/// +/// `CefPreferenceManager::SetPreference` marks only `value` as optional, so CEF's +/// shim opens with `DCHECK(error); if (!error) { return 0; }` and a [`None`] error +/// makes the call fail before the preference service is ever consulted. +/// +/// [`CefString::default`] is not a substitute: it builds the borrowed-none +/// variant, which converts to a null pointer again. `CefString::from("")` builds +/// the owned variant, which converts to a real, writable pointer and frees +/// whatever CEF stores in it when the string is dropped. +pub(crate) fn set_preference_error_slot() -> CefString { + CefString::from("") +} + +/// `devtools.availability`, the profile preference `DevToolsWindow::AllowDevToolsFor` +/// consults on every path that opens DevTools. +/// +/// CEF gates more than the DevTools window on it: `CefBrowserHost::SendDevToolsMessage` +/// is refused on a profile where this is `kDisallowed`, and refused silently — the call +/// still reports success and no response, result or event is ever delivered to a +/// `CefDevToolsMessageObserver`. That makes it unusable as a hardening switch here, since +/// the runtime drives its own startup over the DevTools protocol. See +/// [`DevToolsPolicy`](crate::DevToolsPolicy). +pub(crate) const DEVTOOLS_AVAILABILITY: &str = "devtools.availability"; + +/// The default `devtools.availability` value, matching Chromium's +/// `DeveloperToolsAvailability::kDisallowedForForceInstalledExtensions` — DevTools are +/// available everywhere but on a force-installed extension, which a CEF application has +/// none of. `2` is the value that forbids them outright. +pub(crate) const DEVTOOLS_ALLOWED: i64 = 0; + +/// `devtools.remote_debugging.allowed`, the local-state preference +/// `RemoteDebuggingServer::GetInstance` consults before it will start a server for +/// `--remote-debugging-port` or `--remote-debugging-pipe`. +pub(crate) const REMOTE_DEBUGGING_ALLOWED: &str = "devtools.remote_debugging.allowed"; + +/// Chromium profile preferences forced off for every webview, with the reason +/// each one is unwanted in an application webview: +/// +/// * `credentials_enable_service` - Chrome offers to save credentials typed +/// into any form; an app's login form is not the browser's business. +/// * `profile.password_manager_leak_detection` - on by default in Chromium, it +/// sends a hashed prefix of credentials typed into any form to Google to check +/// them against known breaches. +/// * `autofill.profile_enabled` / `autofill.credit_card_enabled` - the same +/// deal for postal addresses and payment cards, which additionally sync into +/// the user's Google account. +/// * `translate.enabled` - the translate bubble both covers app UI and ships +/// page text off to Google's translation service to decide whether to offer. +/// * `alternate_error_pages.enabled` - on a failed navigation Chrome sends the +/// URL that failed to Google to fetch suggestions for it. +/// * `search.suggest_enabled` - streams typed input to the profile's default +/// search engine; an app has no omnibox for this to serve. +/// * `privacy_sandbox.m1.*` - the Topics, Protected Audience and attribution +/// reporting APIs. Chrome only turns these on after its own consent flow, which +/// never runs in CEF, so today they are already off; pinning them means a future +/// Chromium that flips the default cannot quietly enrol an application's users in +/// interest-based advertising. +/// +/// `safebrowsing.enabled` is deliberately absent - see the module docs. +const PREFERENCES: &[(&str, bool)] = &[ + ("credentials_enable_service", false), + ("profile.password_manager_leak_detection", false), + ("autofill.profile_enabled", false), + ("autofill.credit_card_enabled", false), + ("translate.enabled", false), + ("alternate_error_pages.enabled", false), + ("search.suggest_enabled", false), + ("privacy_sandbox.m1.topics_enabled", false), + ("privacy_sandbox.m1.fledge_enabled", false), + ("privacy_sandbox.m1.ad_measurement_enabled", false), +]; + +/// Converts a JSON value into the `CefValue` the preference API takes. +/// +/// Chromium preferences are `base::Value`s of every shape - `proxy` is a dictionary, +/// `webrtc.ip_handling_policy` a string, `devtools.availability` an integer - so the +/// runtime carries them as [`serde_json::Value`] and converts here. Returns [`None`] when +/// CEF will not allocate, which is the only failure mode: every JSON shape has a +/// `base::Value` counterpart. +fn to_cef_value(value: &serde_json::Value) -> Option { + let cef_value = cef::value_create()?; + + match value { + serde_json::Value::Null => { + cef_value.set_null(); + } + serde_json::Value::Bool(value) => { + cef_value.set_bool(i32::from(*value)); + } + serde_json::Value::Number(number) => { + // Chromium stores an integer preference as an int and refuses a double in its + // place, so an integral JSON number has to stay integral. `as_i64` answers only + // for numbers that really are integers. + match number.as_i64() { + Some(integer) => { + let Ok(integer) = i32::try_from(integer) else { + log::debug!("preference value {integer} does not fit in the int Chromium stores"); + return None; + }; + cef_value.set_int(integer); + } + None => { + cef_value.set_double(number.as_f64()?); + } + } + } + serde_json::Value::String(string) => { + cef_value.set_string(Some(&CefString::from(string.as_str()))); + } + serde_json::Value::Array(items) => { + let list = cef::list_value_create()?; + for (index, item) in items.iter().enumerate() { + let mut item = to_cef_value(item)?; + list.set_value(index, Some(&mut item)); + } + let mut list = list; + cef_value.set_list(Some(&mut list)); + } + serde_json::Value::Object(entries) => { + let dictionary = cef::dictionary_value_create()?; + for (key, entry) in entries { + let mut entry = to_cef_value(entry)?; + dictionary.set_value(Some(&CefString::from(key.as_str())), Some(&mut entry)); + } + let mut dictionary = dictionary; + cef_value.set_dictionary(Some(&mut dictionary)); + } + } + + Some(cef_value) +} + +/// Applies [`PREFERENCES`], then `overrides`, to `request_context`. +/// +/// Must be called after the request context has finished initializing - a +/// preference cannot be written before the underlying Chromium `Profile` +/// exists. +/// +/// `overrides` are the application's own, from `Cef::profile_preference` and the typed +/// options that write one. They are written last so that naming a preference this module +/// disables turns it back on, and so that a repeated name keeps its last value. +pub(crate) fn apply_app_webview_preferences( + request_context: &RequestContext, + overrides: &[(String, serde_json::Value)], +) { + let defaults = PREFERENCES + .iter() + .map(|(name, enabled)| ((*name).to_string(), serde_json::Value::Bool(*enabled))); + + for (name, value) in defaults.chain(overrides.iter().cloned()) { + let _ = set_preference(request_context, &name, &value); + } +} + +/// Applies the application's default content settings to `request_context`. +/// +/// A null URL pair is how CEF spells "the default for every origin", which is the +/// application-wide policy an embedder wants: a page cannot ask for a permission whose +/// default is `BLOCK`, and one whose default is `ALLOW` never prompts. +pub(crate) fn apply_default_content_settings( + request_context: &RequestContext, + settings: &[(ContentSettingTypes, ContentSettingValues)], +) { + for (content_type, value) in settings { + request_context.set_content_setting(None, None, *content_type, *value); + } +} + +/// Writes one preference on any store that has a preference manager, skipping it when +/// this Chrome build will not take it. Returns whether it was written. +/// +/// Which preferences a given Chrome build registers as writable varies, and there is one +/// request context per webview, so a refused preference is logged at debug rather than +/// warned about. A caller for whom the write is the whole point — the proxy is the +/// standing case — checks the return value and says more. +#[must_use] +pub(crate) fn set_preference( + manager: &M, + name: &str, + value: &serde_json::Value, +) -> bool { + if manager.can_set_preference(Some(&name.into())) != 1 { + log::debug!("the CEF preference store does not allow setting the {name} preference"); + return false; + } + + let Some(value) = to_cef_value(value) else { + log::debug!("failed to build a CEF value for the {name} preference"); + return false; + }; + + let mut value = value; + let mut error = set_preference_error_slot(); + if manager.set_preference(Some(&name.into()), Some(&mut value), Some(&mut error)) != 1 { + log::debug!("failed to apply the {name} preference: {error}"); + return false; + } + + true +} + +/// Applies the preferences that live in Chromium's local state rather than in a profile. +/// +/// Called once the CEF context is initialized, which is when +/// `cef::preference_manager_get_global()` starts answering. +pub(crate) fn apply_global_preferences(overrides: &[(String, serde_json::Value)]) { + if overrides.is_empty() { + return; + } + + let Some(manager) = cef::preference_manager_get_global() else { + log::debug!("the global CEF preference manager is unavailable; skipping global preferences"); + return; + }; + + for (name, value) in overrides { + let _ = set_preference(&manager, name, value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_disabled_preferences_are_all_disabled() { + // Every entry exists to turn something off; an entry set to `true` would be a + // default this module has no business asserting. + for (name, enabled) in PREFERENCES { + assert!(!enabled, "{name} is listed as a preference forced off"); + } + } + + #[test] + fn safe_browsing_is_not_disabled_by_default() { + assert!( + !PREFERENCES + .iter() + .any(|(name, _)| *name == "safebrowsing.enabled"), + "Safe Browsing stays on unless the application asks otherwise; see the module docs" + ); + } + + #[test] + fn the_privacy_sandbox_apis_are_pinned_off() { + for api in [ + "privacy_sandbox.m1.topics_enabled", + "privacy_sandbox.m1.fledge_enabled", + "privacy_sandbox.m1.ad_measurement_enabled", + ] { + assert!(PREFERENCES.iter().any(|(name, _)| *name == api)); + } + } +} diff --git a/src/cef_impl/request_context.rs b/src/cef_impl/request_context.rs index 608ddf6..ad32654 100644 --- a/src/cef_impl/request_context.rs +++ b/src/cef_impl/request_context.rs @@ -18,7 +18,7 @@ use sha2::{Digest, Sha256}; use tauri_runtime::webview::WebviewAttributes; use tauri_utils::Theme; -use crate::cef_impl::request_handler; +use crate::cef_impl::{preferences, request_handler}; #[inline] fn theme_to_color_variant(theme: Option) -> ColorVariant { @@ -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 { @@ -227,6 +227,39 @@ wrap_request_context_handler! { } } +/// Applies a fixed-server proxy to a request context via the Chromium `proxy` +/// preference. Must be called after the request context has initialized. +/// +/// This is the per-webview `WebviewAttributes::proxy_url`; an application-wide proxy is +/// the same preference written through `Cef::proxy`, and whichever is applied last wins +/// on a given context. +fn apply_proxy(request_context: &RequestContext, proxy_url: &url::Url) { + let scheme = match proxy_url.scheme() { + "socks5" | "socks5h" => "socks5", + "socks4" | "socks4a" => "socks4", + "https" => "https", + _ => "http", + }; + let Some(host) = proxy_url.host_str() else { + log::warn!("ignoring proxy URL without a host: {proxy_url}"); + return; + }; + let server = match proxy_url.port_or_known_default() { + Some(port) => format!("{scheme}://{host}:{port}"), + None => format!("{scheme}://{host}"), + }; + + // A webview that asked for a proxy and did not get one would silently send its traffic + // straight out, so unlike most preferences this one is worth a warning. + if !preferences::set_preference( + request_context, + "proxy", + &serde_json::json!({ "mode": "fixed_servers", "server": server }), + ) { + log::warn!("failed to apply the proxy preference to the CEF request context"); + } +} + /// Creates a per-webview [`RequestContext`], registers Tauri's custom URI /// scheme handler factories on it, and arranges for `on_initialized` to fire /// once the underlying Chromium `Profile` is fully created. @@ -255,54 +288,11 @@ wrap_request_context_handler! { /// hop otherwise), so by the time the browser finally issues its first /// navigation against any of these schemes the factories have been wired up /// on the IO thread. -/// Applies a fixed-server proxy to a request context via the Chromium `proxy` -/// preference. Must be called after the request context has initialized. -fn apply_proxy(request_context: &RequestContext, proxy_url: &url::Url) { - use cef::{ImplDictionaryValue, ImplValue}; - - let scheme = match proxy_url.scheme() { - "socks5" | "socks5h" => "socks5", - "socks4" | "socks4a" => "socks4", - "https" => "https", - _ => "http", - }; - let Some(host) = proxy_url.host_str() else { - log::warn!("ignoring proxy URL without a host: {proxy_url}"); - return; - }; - let server = match proxy_url.port_or_known_default() { - Some(port) => format!("{scheme}://{host}:{port}"), - None => format!("{scheme}://{host}"), - }; - - let pref_name = "proxy"; - if request_context.can_set_preference(Some(&pref_name.into())) != 1 { - log::warn!("the CEF request context does not allow setting the proxy preference"); - return; - } - - // Build `{ "mode": "fixed_servers", "server": "://:" }`. - let Some(dict) = cef::dictionary_value_create() else { - return; - }; - dict.set_string(Some(&"mode".into()), Some(&"fixed_servers".into())); - dict.set_string(Some(&"server".into()), Some(&server.as_str().into())); - - let Some(value) = cef::value_create() else { - return; - }; - let mut dict = dict; - value.set_dictionary(Some(&mut dict)); - - let mut value = value; - if request_context.set_preference(Some(&pref_name.into()), Some(&mut value), None) != 1 { - log::error!("failed to apply the proxy preference to the CEF request context"); - } -} - pub(crate) fn request_context_from_webview_attributes<'a>( global_cache_path: &Path, webview_attributes: &WebviewAttributes, + profile_preferences: Arc>, + content_settings: Arc>, custom_schemes: impl IntoIterator, custom_protocol_scheme: &str, scheme_registry: request_handler::SchemeRegistry, @@ -328,14 +318,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() }; @@ -349,11 +331,15 @@ pub(crate) fn request_context_from_webview_attributes<'a>( let wrapped_callback: RequestContextInitContinuation = Box::new({ let rc_holder = rc_holder.clone(); move |rc| { - // The proxy preference can only be set once the request context's - // underlying profile has finished initializing, which is exactly what - // this continuation signals. - if let (Some(rc), Some(proxy_url)) = (rc.as_ref(), proxy_url.as_ref()) { - apply_proxy(rc, proxy_url); + // Preferences and content settings can only be set once the request context's + // underlying profile has finished initializing, which is exactly what this + // continuation signals. + if let Some(rc) = rc.as_ref() { + preferences::apply_app_webview_preferences(rc, &profile_preferences); + preferences::apply_default_content_settings(rc, &content_settings); + if let Some(proxy_url) = proxy_url.as_ref() { + apply_proxy(rc, proxy_url); + } } on_initialized(rc); let _released = rc_holder.lock().unwrap().take(); @@ -366,7 +352,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 +360,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..f98b63a 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,135 @@ 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>, + certificate_errors: crate::CertificateErrorPolicy, } impl RequestHandler { - fn on_render_process_terminated( + /// Answers a TLS certificate that does not validate, per + /// [`CertificateErrorPolicy`](crate::CertificateErrorPolicy). + /// + /// Returning 0 hands the error back to Chromium, which shows the SSL interstitial + /// with its "proceed anyway" link. Cancelling instead means dropping the callback + /// without continuing it: CEF reads a `false` return as "cancel this request", and + /// the page gets a network error it cannot click past. + fn on_certificate_error( &self, _browser: Option<&mut Browser>, - _status: TerminationStatus, - _error_code: ::std::os::raw::c_int, - _error_string: Option<&CefString>, + cert_error: Errorcode, + request_url: Option<&CefString>, + _ssl_info: Option<&mut Sslinfo>, + _callback: Option<&mut Callback>, + ) -> ::std::os::raw::c_int { + match self.certificate_errors { + crate::CertificateErrorPolicy::ChromeInterstitial => 0, + crate::CertificateErrorPolicy::Cancel => { + // The URL can carry credentials and tokens, so only its origin is logged. + let origin = request_url + .map(ToString::to_string) + .and_then(|url| url::Url::parse(&url).ok()) + .map(|url| url.origin().ascii_serialization()) + .unwrap_or_else(|| "an unknown origin".to_string()); + log::warn!( + "cancelling a request to {origin}: its TLS certificate did not validate \ + (Chromium error {cert_error:?}), and the runtime is set to \ + CertificateErrorPolicy::Cancel" + ); + 1 + } + } + } + fn on_render_process_terminated( + &self, + 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 +243,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 +257,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 +291,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())); +wrap_with_args! { + wrap_resource_handler => WebResourceHandlerArgs; - if let Some(length) = response_length { - *length = -1; - } - - if let Some(redirect_url) = redirect_url { - let _ = std::mem::take(redirect_url); - } -} - -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 +327,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 +341,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 +416,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 +460,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 +514,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 +563,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 +629,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 +648,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/environment.rs b/src/environment.rs new file mode 100644 index 0000000..0e2926b --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,202 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! The diagnostic environment variables Chromium and CEF read behind the application's +//! back, and the policy that decides whether they are honoured. +//! +//! Chromium is a browser, and a browser is expected to let whoever runs it capture its +//! own traffic and redirect its own crash reports. An application that embeds Chromium +//! inherits those hooks without asking for them, and in a shipped application they are +//! not developer conveniences any more: anyone who can set a variable in the +//! application's environment can decrypt every TLS session it makes, or point its +//! minidumps — which carry process memory — at a server of their choosing. +//! +//! [`DebugEnvironment`] is the switch. Neither variable group is reachable through a CEF +//! setting, so each is answered where Chromium reads it. +//! +//! # TLS key logging is answered on the command line +//! +//! `content/browser/network_service_instance_impl.cc` consults `SSLKEYLOGFILE` only when +//! `--ssl-key-log-file` is absent, and an empty switch value logs a warning and creates no +//! key logger. Appending the empty switch is therefore a complete answer that needs no +//! change to the process environment, and the runtime appends it only when the variable is +//! actually set — so the warning appears exactly when somebody was trying to log keys. +//! +//! # The crash reporter overrides are answered in the environment +//! +//! CEF reads its three crash variables from `BasicStartupComplete`, before any hook the +//! embedder can install, so there is nothing to append and the variables have to go. That +//! write is [`std::env::remove_var`], which is why it happens once, during runtime +//! initialization, and only for a variable that is actually set. + +/// Whether Chromium and CEF may read their diagnostic environment variables. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DebugEnvironment { + /// Honour them in development builds (`tauri::is_dev()`), refuse them in release + /// builds. + #[default] + Auto, + /// Always honour them. + /// + /// Appropriate for a build whose users are expected to debug it — an internal tool, a + /// QA build — and wrong for one handed to the public. + Allow, + /// Always refuse them, in every build profile. + Deny, +} + +/// The environment variable whose answer is the `--ssl-key-log-file` switch. +/// +/// The network service writes the pre-master secret of every TLS session to the file it +/// names, which is exactly what a packet capture needs to decrypt the application's +/// traffic in full. +const SSL_KEY_LOG_FILE: &str = "SSLKEYLOGFILE"; + +/// CEF's crash reporter overrides, and what each one does. +/// +/// * `CEF_CRASH_REPORTER_SERVER_URL` — replaces the crash report upload URL from +/// `crash_reporter.cfg`. A minidump carries stack and heap memory, so a redirected URL +/// is a memory exfiltration channel. +/// * `CEF_CRASH_REPORTER_RATE_LIMIT_ENABLED` — lifts the upload rate limit that keeps a +/// crash loop from becoming a flood. +/// * `BREAKPAD_DUMP_LOCATION` — redirects where minidumps are written on Windows. +/// +/// All three are only consulted when the application ships a `crash_reporter.cfg`, so +/// removing them costs nothing when it does not. +const CRASH_REPORTER_VARIABLES: &[(&str, &str)] = &[ + ( + "CEF_CRASH_REPORTER_SERVER_URL", + "redirecting crash report uploads, which carry process memory", + ), + ( + "CEF_CRASH_REPORTER_RATE_LIMIT_ENABLED", + "overriding the crash report upload rate limit", + ), + ( + "BREAKPAD_DUMP_LOCATION", + "redirecting where minidumps are written", + ), +]; + +/// Whether `policy` refuses the variables in a build where `is_dev` says what it says. +/// +/// Kept separate from acting on the answer so the decision can be unit tested without +/// touching the process environment. +fn refuses_debug_variables(policy: DebugEnvironment, is_dev: bool) -> bool { + match policy { + DebugEnvironment::Auto => !is_dev, + DebugEnvironment::Allow => false, + DebugEnvironment::Deny => true, + } +} + +/// Whether a variable is set to something Chromium would act on. +fn is_set(name: &str) -> bool { + std::env::var_os(name).is_some_and(|value| !value.is_empty()) +} + +/// Whether the runtime should append an empty `--ssl-key-log-file`, which is how +/// `SSLKEYLOGFILE` is refused. +/// +/// Answers `false` when the variable is not set, so the switch — and the warning Chromium +/// logs for it — only appears when something was actually asking for a key log. +pub(crate) fn neutralizes_tls_key_log(policy: DebugEnvironment, is_dev: bool) -> bool { + if !refuses_debug_variables(policy, is_dev) || !is_set(SSL_KEY_LOG_FILE) { + return false; + } + + log::warn!( + "ignoring the {SSL_KEY_LOG_FILE} environment variable: it asks Chromium to log the TLS \ + session keys that decrypt this application's network traffic. Set \ + DebugEnvironment::Allow to honour it." + ); + true +} + +/// Removes CEF's crash reporter overrides from the process environment when `policy` +/// refuses them. +/// +/// Must run before the first CEF call: CEF reads these from +/// `ChromeMainDelegateCef::BasicStartupComplete`, which `cef::initialize` reaches, and +/// child processes inherit the environment of the browser process that spawned them, so a +/// variable removed here is gone from the whole process tree. +/// +/// # Safety +/// +/// Calls [`std::env::remove_var`], which is unsound while another thread reads or writes +/// the environment concurrently. The caller must be the runtime's own initialization, +/// which runs on the main thread before CEF exists and before this runtime starts a +/// thread of its own. +pub(crate) fn remove_crash_reporter_overrides(policy: DebugEnvironment, is_dev: bool) { + if !refuses_debug_variables(policy, is_dev) { + return; + } + + for (name, effect) in CRASH_REPORTER_VARIABLES { + if !is_set(name) { + continue; + } + + log::warn!( + "ignoring the {name} environment variable: it asks CEF for {effect}. Set \ + DebugEnvironment::Allow to honour it." + ); + // SAFETY: documented on this function; the runtime applies the policy from its own + // initialization, before CEF exists and before it starts a thread of its own. + unsafe { std::env::remove_var(name) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_follows_the_build_profile() { + assert!( + !refuses_debug_variables(DebugEnvironment::Auto, true), + "a development build keeps its debugging hooks" + ); + assert!( + refuses_debug_variables(DebugEnvironment::Auto, false), + "a shipped build must not hand its TLS keys to whoever sets a variable" + ); + } + + #[test] + fn explicit_policies_ignore_the_build_profile() { + for is_dev in [false, true] { + assert!(!refuses_debug_variables(DebugEnvironment::Allow, is_dev)); + assert!(refuses_debug_variables(DebugEnvironment::Deny, is_dev)); + } + } + + #[test] + fn a_permissive_policy_never_touches_the_command_line() { + // Asserted without depending on the ambient environment: `Allow` short-circuits + // before the variable is even read. + assert!(!neutralizes_tls_key_log(DebugEnvironment::Allow, false)); + assert!(!neutralizes_tls_key_log(DebugEnvironment::Auto, true)); + } + + #[test] + fn every_crash_variable_is_described() { + // The name and the effect both go into a warning the user reads. + for (name, effect) in CRASH_REPORTER_VARIABLES { + assert!(!name.is_empty()); + assert!(!effect.is_empty()); + } + } + + #[test] + fn the_tls_variable_is_not_also_removed_from_the_environment() { + // It is answered on the command line instead, which needs no environment write. + assert!( + !CRASH_REPORTER_VARIABLES + .iter() + .any(|(name, _)| *name == SSL_KEY_LOG_FILE) + ); + } +} diff --git a/src/external_message_pump/linux.rs b/src/external_message_pump/linux.rs index 8a3fefb..dd37847 100644 --- a/src/external_message_pump/linux.rs +++ b/src/external_message_pump/linux.rs @@ -60,7 +60,7 @@ unsafe impl Send for PlatformPump {} impl PlatformPump { pub(super) fn new(state: Weak) -> Self { - // The runtime services callbacks from GLib's default MainContext. + // winit-gtk4 drives callbacks from GLib's default MainContext. let context = glib::MainContext::default(); // Create our wakeup pipe, which is used to flag when work was scheduled. @@ -219,15 +219,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/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..993a4e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,38 +6,44 @@ #![allow(clippy::too_many_arguments)] mod cef_impl; -mod compat; -mod config; +mod devtools; +mod dialog; +/// The diagnostic environment variables Chromium and CEF read, and the policy over them. +mod environment; mod external_message_pump; +mod frame; +pub use dialog::{ + NativeDialogKind, NativeDialogObservation, NativeDialogSnapshot, NativeDialogToken, +}; +pub use environment::DebugEnvironment; +mod frame_navigation; +/// The languages the user asked their operating system for. +mod locale; +mod macros; mod platform; -mod policy; +mod popup; mod runtime; -mod streaming; -#[cfg(target_os = "linux")] -mod wayland; +// `SandboxPolicy` itself is public API and lives in `runtime`; this module holds the +// decision behind it. +mod sandbox; +/// Helpers for the Chromium command line the runtime hands to CEF. +mod switches; +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/locale.rs b/src/locale.rs new file mode 100644 index 0000000..c647d56 --- /dev/null +++ b/src/locale.rs @@ -0,0 +1,233 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! The languages the user asked their operating system for, as Chromium wants them. +//! +//! CEF appends `--lang=en-US` whenever `CefSettings.locale` is empty, and with no +//! `accept_language_list` of its own the accept-language list falls back to that locale. +//! A CEF application therefore sends `Accept-Language: en-US,en` and reports +//! `navigator.language === "en-US"` for every user on earth, whatever their system is set +//! to — which silently breaks server-side localization and any page that branches on the +//! browser language. +//! +//! `CefSettings.locale` cannot simply follow the system, because it selects which +//! `locales/*.pak` Chromium loads its own localized strings from and Tauri's bundler +//! packages only `en-US.pak`. `accept_language_list` has no such constraint: it is a plain +//! list of language codes, so it is the one the runtime derives from the system. +//! +//! CEF expands whatever it is given — `pt-BR` becomes `pt-BR,pt` with quality values — +//! through `net::HttpUtil::ExpandLanguageList`, so this module only has to produce the +//! ordered list of codes the user actually chose. + +/// The user's preferred languages as a comma-delimited list of BCP-47 codes, or [`None`] +/// when the system does not say. +/// +/// The result feeds `CefSettings.accept_language_list`. +pub(crate) fn system_accept_language_list() -> Option { + let languages = preferred_languages(); + let list = normalize_language_list(languages.iter().map(String::as_str)); + (!list.is_empty()).then(|| list.join(",")) +} + +/// Cleans up the language tags a platform reports, into the spelling Chromium expects. +/// +/// POSIX locale names are not BCP-47: they spell the region after an underscore and carry +/// a charset and a modifier the web has no use for (`pt_BR.UTF-8@euro`). The placeholder +/// locales are dropped rather than sent — `C` and `POSIX` mean "no preference", and +/// asking a server for a language called `c` is worse than asking for nothing. +/// +/// Duplicates are dropped keeping first position, because the order is the user's +/// preference order. +fn normalize_language_list<'a>(languages: impl Iterator) -> Vec { + let mut normalized: Vec = Vec::new(); + + for language in languages { + let Some(tag) = normalize_language(language) else { + continue; + }; + if !normalized.iter().any(|existing| existing == &tag) { + normalized.push(tag); + } + } + + normalized +} + +/// Normalizes one language tag, returning [`None`] for one that must not be sent. +fn normalize_language(language: &str) -> Option { + // `pt_BR.UTF-8@euro` -> `pt_BR` + let tag = language + .split(['.', '@']) + .next() + .unwrap_or_default() + .trim() + .replace('_', "-"); + + if tag.is_empty() || tag.eq_ignore_ascii_case("C") || tag.eq_ignore_ascii_case("POSIX") { + return None; + } + + // A tag is language[-Script][-REGION]; anything else is not something to send to a + // server, and a stray value in `Accept-Language` is a fingerprinting surface. + let mut parts = tag.split('-'); + let language = parts.next()?; + if language.len() < 2 || language.len() > 8 || !language.chars().all(|c| c.is_ascii_alphabetic()) + { + return None; + } + if !parts.all(|part| { + !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()) + }) { + return None; + } + + Some(tag) +} + +/// The ordered languages the system reports, in whatever spelling it uses. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +fn preferred_languages() -> Vec { + // `LANGUAGE` is the only one that carries a *list*, and gettext gives it priority over + // the `LC_*` variables for exactly the "I read these languages, in this order" question + // being asked here. The rest name a single locale, in the precedence POSIX defines. + if let Some(languages) = std::env::var_os("LANGUAGE") + .map(|value| value.to_string_lossy().into_owned()) + .filter(|value| !value.is_empty()) + { + let languages: Vec = languages.split(':').map(ToString::to_string).collect(); + if !normalize_language_list(languages.iter().map(String::as_str)).is_empty() { + return languages; + } + } + + for variable in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Some(value) = std::env::var_os(variable) + .map(|value| value.to_string_lossy().into_owned()) + .filter(|value| !value.is_empty()) + { + return vec![value]; + } + } + + Vec::new() +} + +/// The ordered languages the system reports, in whatever spelling it uses. +#[cfg(target_os = "macos")] +fn preferred_languages() -> Vec { + // Already BCP-47, already in the order set in System Settings. + objc2_foundation::NSLocale::preferredLanguages() + .iter() + .map(|language| language.to_string()) + .collect() +} + +/// The ordered languages the system reports, in whatever spelling it uses. +#[cfg(windows)] +fn preferred_languages() -> Vec { + use windows::Win32::Globalization::GetUserDefaultLocaleName; + + // `LOCALE_NAME_MAX_LENGTH`, which is the documented bound on what this can write. + let mut buffer = [0u16; 85]; + // SAFETY: the binding takes the buffer as a slice and derives the length from it. + let written = unsafe { GetUserDefaultLocaleName(&mut buffer) }; + if written <= 0 { + return Vec::new(); + } + + // The count includes the terminating null. + let name = String::from_utf16_lossy(&buffer[..(written as usize).saturating_sub(1)]); + if name.is_empty() { + Vec::new() + } else { + vec![name] + } +} + +#[cfg(not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "macos", + windows +)))] +fn preferred_languages() -> Vec { + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn normalize(languages: &[&str]) -> Vec { + normalize_language_list(languages.iter().copied()) + } + + #[test] + fn posix_locale_names_become_language_tags() { + assert_eq!(normalize(&["pt_BR.UTF-8"]), ["pt-BR"]); + assert_eq!(normalize(&["de_DE@euro"]), ["de-DE"]); + assert_eq!(normalize(&["fr_CA.ISO-8859-1@currency"]), ["fr-CA"]); + } + + #[test] + fn tags_that_are_already_bcp47_are_left_alone() { + assert_eq!( + normalize(&["pt-BR", "zh-Hans-CN", "en"]), + ["pt-BR", "zh-Hans-CN", "en"] + ); + } + + #[test] + fn placeholder_locales_are_dropped() { + // "C" and "POSIX" mean "no preference"; sending them asks servers for a language + // called "c". + assert!(normalize(&["C"]).is_empty()); + assert!(normalize(&["POSIX"]).is_empty()); + assert!(normalize(&["C.UTF-8"]).is_empty()); + assert_eq!(normalize(&["C", "pt_BR"]), ["pt-BR"]); + } + + #[test] + fn preference_order_is_kept_and_duplicates_dropped() { + assert_eq!( + normalize(&["pt_BR.UTF-8", "pt-BR", "en_US.UTF-8"]), + ["pt-BR", "en-US"] + ); + } + + #[test] + fn malformed_tags_never_reach_the_accept_language_header() { + assert!(normalize(&[""]).is_empty()); + assert!(normalize(&[" "]).is_empty()); + assert!( + normalize(&["e"]).is_empty(), + "a one-letter language is not one" + ); + assert!( + normalize(&["en-"]).is_empty(), + "a trailing separator is malformed" + ); + assert!(normalize(&["en-US-"]).is_empty()); + assert!(normalize(&["1234"]).is_empty()); + assert!( + normalize(&["en US"]).is_empty(), + "a space is not a separator" + ); + } + + #[test] + fn nothing_reported_produces_no_list() { + assert!(normalize(&[]).is_empty()); + assert!(normalize(&["C", "POSIX"]).is_empty()); + } +} 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..ea63f77 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -9,4 +9,4 @@ mod utils; mod webview; mod window; -pub use utils::install_x_error_handlers; +pub(crate) use window::CefX11Host; 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..9c18b9e 100644 --- a/src/platform/linux/webview.rs +++ b/src/platform/linux/webview.rs @@ -13,6 +13,47 @@ 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()); + } + // Browsers are created under (and reparented into) the X11 host, not the GTK toplevel. + (status != 0).then_some(native_parent == parent.cef_host_handle() 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,31 +100,11 @@ 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(); + // Linux reparents into the GTK content-area X11 host, unlike Windows/macOS + // where the CEF host handle is the native window/view. + let parent_xid = parent.cef_host_handle(); with_cef_display((), |xlib, display| unsafe { (xlib.XReparentWindow)(display, xid, parent_xid as xlib::Window, 0, 0); @@ -127,14 +148,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..64d1a75 100644 --- a/src/platform/linux/window.rs +++ b/src/platform/linux/window.rs @@ -3,94 +3,235 @@ // SPDX-License-Identifier: MIT use raw_window_handle::{HasWindowHandle, RawWindowHandle}; -use std::os::raw::{c_int, c_uint, c_ulong}; +use std::{ + cell::{Cell, RefCell}, + os::raw::c_ulong, + rc::Rc, +}; use tauri_runtime::ProgressBarState; +use tauri_runtime::dpi::PhysicalSize; use tauri_utils::config::Color; +use winit::platform::gtk4::WindowExtGtk4; use crate::window::AppWindow; use super::{taskbar, utils::set_wm_state}; +/// 24-bit X11 parent for CEF browser children. +/// +/// GTK owns the toplevel layout and menu widgets, while CEF creates native X11 +/// child windows. This host is kept sized to GTK's content box so CEF renders +/// below GTK UI instead of covering it. The host uses a 24-bit TrueColor visual +/// because CEF does not render correctly with the inherited GTK window visual on +/// all X11 setups. +/// +/// Hierarchy: +/// - GtkApplicationWindow +/// - GtkBox +/// - menu +/// - content GtkBox +/// - CefX11Host, positioned over the content GtkBox +/// - CEF webview +/// - CEF webview +/// - CEF webview +pub(crate) struct CefX11Host { + default_vbox: gtk::Box, + xid: c_ulong, + colormap: c_ulong, + geometry: Rc, + /// CSS provider currently backing this window's background color, kept so it can be removed + /// from the display instead of accumulating one provider per `set_background_color` call. + background_color_provider: RefCell>, +} + +/// Geometry of the X11 host, shared with the GTK `layout` handler that keeps it up to date. +#[derive(Default)] +struct HostGeometry { + size: Cell>, + /// Set when [`Self::size`] changed and the CEF children have not been laid out against it yet. + needs_relayout: Cell, +} + +impl CefX11Host { + pub(crate) fn new(window: &dyn winit::window::Window) -> Option { + use gtk::prelude::*; + + let gtk_window = window.gtk_window()?; + let default_vbox = gtk::Box::new(gtk::Orientation::Vertical, 0); + default_vbox.set_hexpand(true); + default_vbox.set_vexpand(true); + + let webview_area = gtk::Box::new(gtk::Orientation::Vertical, 0); + webview_area.set_hexpand(true); + webview_area.set_vexpand(true); + + default_vbox.append(&webview_area); + gtk_window.set_child(Some(&default_vbox)); + + let parent_xid = window_xid(window); + let initial_size = window.surface_size(); + let (xid, colormap) = create_cef_container(parent_xid, initial_size)?; + + let geometry = Rc::new(HostGeometry { + size: Cell::new(initial_size), + needs_relayout: Cell::new(false), + }); + + if let Some(surface) = gtk_window.surface() { + let gtk_window = gtk_window.clone(); + let layout_webview_area = webview_area.clone(); + let layout_geometry = geometry.clone(); + surface.connect_layout(move |surface, _, _| { + let size = set_cef_container_bounds( + xid, + >k_window, + &layout_webview_area, + surface.scale_factor().max(1) as f64, + ); + if layout_geometry.size.replace(size) != size { + // The content area changed without the toplevel being resized - a menu bar was + // attached, hidden or shown - so winit emits no `SurfaceResized` and the CEF children + // would keep the bounds computed against the previous host size. + layout_geometry.needs_relayout.set(true); + } + }); + } + + Some(Self { + default_vbox, + xid, + colormap, + geometry, + background_color_provider: RefCell::new(None), + }) + } + + pub(crate) fn default_vbox(&self) -> gtk::Box { + self.default_vbox.clone() + } + + /// CSS class carrying this window's background color. The X11 host id makes it unique per + /// window, since the providers below are registered display-wide. + fn background_color_class(&self) -> String { + format!("tauri-cef-window-background-{}", self.xid) + } + + pub(crate) fn size(&self) -> PhysicalSize { + self.geometry.size.get() + } + + /// Whether the host was resized by GTK since the last time the CEF children were laid out. + pub(crate) fn take_needs_relayout(&self) -> bool { + self.geometry.needs_relayout.replace(false) + } + + fn take_background_color_provider(&self) -> Option { + self.background_color_provider.borrow_mut().take() + } + + fn set_background_color_provider(&self, provider: gtk::CssProvider) { + self.background_color_provider.replace(Some(provider)); + } +} + +impl Drop for CefX11Host { + fn drop(&mut self) { + if let Some(provider) = self.background_color_provider.borrow_mut().take() { + gtk::style_context_remove_provider_for_display( + >k::prelude::WidgetExt::display(&self.default_vbox), + &provider, + ); + } + + super::utils::with_x11((), |xlib, display| unsafe { + (xlib.XDestroyWindow)(display, self.xid); + (xlib.XFreeColormap)(display, self.colormap); + }); + } +} + impl AppWindow { - pub(crate) fn raw_cef_handle(&self) -> cef::sys::cef_window_handle_t { - self.xid() as cef::sys::cef_window_handle_t + pub(crate) fn cef_host_handle(&self) -> cef::sys::cef_window_handle_t { + self.cef_host.xid as cef::sys::cef_window_handle_t } pub(crate) fn xid(&self) -> c_ulong { - let handle = self - .window - .window_handle() - .expect("failed to get window handle"); - match handle.as_raw() { - RawWindowHandle::Xlib(handle) => handle.window as c_ulong, - RawWindowHandle::Xcb(handle) => handle.window.get() as c_ulong, - other => panic!("expected X11 window handle, got {other:?}"), - } + window_xid(self.window.as_ref()) } - /// 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()); + } + + /// Applies the transient parent recorded by the window builder, if any. + pub(crate) fn apply_transient_for(&self) { + use gtk::prelude::GtkWindowExt; + + let Some(parent) = &self.attrs.transient_for else { + return; + }; + if let Some(window) = self.window.gtk_window() { + window.set_transient_for(Some(parent)); + } } + /// Note that this only covers the GTK widget tree: the CEF browsers are foreign X11 children + /// that GTK does not dispatch events for, so the web contents stay interactive. pub(crate) fn set_enabled(&self, enabled: bool) { - let _ = (self, enabled); - // TODO: implement native window enabled state on Linux/BSD. + use gtk::prelude::*; + + if let Some(window) = self.window.gtk_window() { + window.set_sensitive(enabled); + } } pub(crate) fn is_enabled(&self) -> bool { - let _ = self; - // TODO: query native window enabled state on Linux/BSD. - true + use gtk::prelude::*; + + self + .window + .gtk_window() + .map(|window| window.is_sensitive()) + .unwrap_or(true) } pub(crate) fn set_background_color(&self, color: Option) { - let xid = self.xid(); + use gtk::prelude::*; + + let Some(window) = self.window.gtk_window() else { + return; + }; + + let display = gtk::prelude::WidgetExt::display(&window); + let class = self.cef_host.background_color_class(); + + // GTK has no way to replace a provider, so drop the one installed by the previous call - + // otherwise every call leaves another provider registered on the display for good. + if let Some(previous) = self.cef_host.take_background_color_provider() { + gtk::style_context_remove_provider_for_display(&display, &previous); + } + let Some(color) = color else { + window.remove_css_class(&class); return; }; - super::utils::with_x11((), |xlib, display| unsafe { - let screen = (xlib.XDefaultScreen)(display); - let colormap = (xlib.XDefaultColormap)(display, screen); - let mut xcolor = x11_dl::xlib::XColor { - pixel: 0, - red: u16::from(color.0) * 257, - green: u16::from(color.1) * 257, - blue: u16::from(color.2) * 257, - flags: x11_dl::xlib::DoRed | x11_dl::xlib::DoGreen | x11_dl::xlib::DoBlue, - pad: 0, - }; - - if (xlib.XAllocColor)(display, colormap, &mut xcolor) != 0 { - (xlib.XSetWindowBackground)(display, xid, xcolor.pixel); - (xlib.XClearWindow)(display, xid); - } - }); + let provider = gtk::CssProvider::new(); + let css = format!( + ".{class} {{ background-color: rgba({}, {}, {}, {:.3}); }}", + color.0, + color.1, + color.2, + f64::from(color.3) / 255. + ); + provider.load_from_bytes(>k::glib::Bytes::from_owned(css)); + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + window.add_css_class(&class); + self.cef_host.set_background_color_provider(provider); } pub(crate) fn set_skip_taskbar(&self, skip: bool) { @@ -106,32 +247,96 @@ impl AppWindow { } } -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)( +fn window_xid(window: &dyn winit::window::Window) -> c_ulong { + let handle = window.window_handle().expect("failed to get window handle"); + match handle.as_raw() { + RawWindowHandle::Xlib(handle) => handle.window as c_ulong, + RawWindowHandle::Xcb(handle) => handle.window.get() as c_ulong, + other => panic!("expected X11 window handle, got {other:?}"), + } +} + +fn create_cef_container( + parent_xid: c_ulong, + initial_size: PhysicalSize, +) -> Option<(c_ulong, c_ulong)> { + use x11_dl::xlib::*; + + super::utils::with_x11(None, |xlib, display| unsafe { + let screen = (xlib.XDefaultScreen)(display); + let root = (xlib.XRootWindow)(display, screen); + let mut visual_info: x11_dl::xlib::XVisualInfo = std::mem::zeroed(); + + if (xlib.XMatchVisualInfo)( display, - window, - &mut root, - &mut parent, - &mut children, - &mut child_count, + screen, + 24, + x11_dl::xlib::TrueColor, + &mut visual_info, ) == 0 { - return 0; + return None; } - if !children.is_null() { - (xlib.XFree)(children.cast()); + + let colormap = (xlib.XCreateColormap)(display, root, visual_info.visual, AllocNone); + if colormap == 0 { + return None; } - } - parent + let mut attrs: XSetWindowAttributes = std::mem::zeroed(); + attrs.event_mask = ExposureMask | StructureNotifyMask; + attrs.colormap = colormap; + attrs.border_pixel = 0; + + let xid = (xlib.XCreateWindow)( + display, + parent_xid as Window, + 0, + 0, + initial_size.width, + initial_size.height, + 0, + visual_info.depth, + InputOutput as _, + visual_info.visual, + CWEventMask | CWColormap | CWBorderPixel, + &mut attrs, + ); + if xid == 0 { + (xlib.XFreeColormap)(display, colormap); + return None; + } + + (xlib.XMapWindow)(display, xid); + Some((xid, colormap)) + }) +} + +/// Moves and resizes the X11 host over the GTK content area, returning its new size. +/// +/// GTK4 widget geometry is in logical units while the X11 toplevel GDK creates is sized in device +/// pixels (logical * scale), so every value handed to `XMoveResizeWindow` must be scaled - without +/// it the host covers only 1/scale of the window on HiDPI screens. +fn set_cef_container_bounds( + xid: c_ulong, + gtk_window: >k::ApplicationWindow, + webview_area: >k::Box, + scale_factor: f64, +) -> PhysicalSize { + use gtk::prelude::*; + + let width = (webview_area.width() as f64 * scale_factor).round() as u32; + let height = (webview_area.height() as f64 * scale_factor).round() as u32; + let point = gtk::graphene::Point::new(0.0, 0.0); + let point = webview_area + .compute_point(gtk_window, &point) + .unwrap_or_else(|| gtk::graphene::Point::new(0.0, 0.0)); + let x = (point.x() as f64 * scale_factor).round() as i32; + let y = (point.y() as f64 * scale_factor).round() as i32; + + super::utils::with_x11((), |xlib, display| unsafe { + (xlib.XMoveResizeWindow)(display, xid as _, x, y, width, height); + }); + + PhysicalSize::new(width, height) } 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..e3ea976 100644 --- a/src/platform/macos/window.rs +++ b/src/platform/macos/window.rs @@ -2,34 +2,34 @@ // 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 { + pub(crate) fn cef_host_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/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..d13cda5 100644 --- a/src/platform/windows/window.rs +++ b/src/platform/windows/window.rs @@ -26,12 +26,7 @@ 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 { + pub(crate) fn cef_host_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..16dd6ce 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -33,30 +33,30 @@ use tauri_runtime::{ use tauri_utils::Theme; use winit::{ application::ApplicationHandler, + data_transfer::{DataTransferId, TypeHint}, event::{StartCause, WindowEvent as WinitWindowEvent}, event_loop::{ - ActiveEventLoop, EventLoop, EventLoopBuilder, EventLoopProxy as WinitEventLoopProxy, + ActiveEventLoop, DndAction, EventLoop, EventLoopBuilder, EventLoopProxy as WinitEventLoopProxy, }, window::WindowId as WinitWindowId, }; +use crate::DebugEnvironment; 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, }, window_handle::SendRawDisplayHandle, }; -#[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( target_os = "linux", target_os = "dragonfly", @@ -64,7 +64,14 @@ use winit::platform::windows::EventLoopBuilderExtWindows; target_os = "netbsd", target_os = "openbsd" ))] -use winit::platform::x11::EventLoopBuilderExtX11; +use winit::platform::gtk4::EventLoopBuilderExtGtk4; +#[cfg(target_os = "macos")] +use winit::platform::macos::EventLoopBuilderExtMacOS; +#[cfg(windows)] +use winit::platform::windows::EventLoopBuilderExtWindows; + +/// 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. /// @@ -75,6 +82,1037 @@ use winit::platform::x11::EventLoopBuilderExtX11; /// in minor releases when a known breaking change is discovered. pub use cef; +/// Which key Chromium uses to encrypt the little it stores encrypted. +/// +/// Chromium's `os_crypt` layer encrypts **cookies and saved passwords** only. Every +/// other piece of web storage — `localStorage`, IndexedDB, Cache Storage, service worker +/// registrations — is written to the cache directory unencrypted whichever variant you +/// pick here, exactly as it is under wry's WebKitGTK and WebView2 backends. +/// +/// The default, [`SecretStorage::Auto`], skips the OS secret store in development builds +/// (`tauri::is_dev()`) and keeps it in release builds. What it skips differs per +/// platform: +/// +/// - on macOS, `os_crypt` stores a random key in a shared "Chromium Safe Storage" +/// keychain item whose ACL is bound to the code signature of the process that reads +/// it. Ad-hoc-signed development builds get a new signature on every rebuild, so macOS +/// puts up the keychain password prompt again after every `cargo build`. +/// - on Linux, `os_crypt` asks the D-Bus secret portal, libsecret or KWallet for the +/// key, which pops a keyring-unlock dialog the first time an app runs. +/// +/// A release build that has to run where there is no secret store at all — a headless +/// session, a container, a CI image — needs [`SecretStorage::Mock`], because there `Auto` +/// asks for a store that is not there. +/// +/// # Security +/// +/// The mock keychain (`--use-mock-keychain`) and the Linux `basic` password store do not +/// derive a secret key: they encrypt with a key derived from a **hard-coded constant** +/// compiled into Chromium (`mock_password` and `peanuts` respectively). Both constants +/// are public, so cookies encrypted with them have **no meaningful protection at rest** — +/// anyone who can read the cache directory can decrypt them. +/// +/// # Switching modes invalidates stored cookies +/// +/// Cookies encrypted with one key cannot be read back with another, and development and +/// release builds share the same default cache directory +/// (`{user cache}/{identifier}/cef`). Moving an app between [`SecretStorage::Mock`] and +/// [`SecretStorage::System`] — including the implicit move [`SecretStorage::Auto`] makes +/// when a dev build is followed by a release build — therefore drops the cookies stored +/// under the previous key, logging users out. Set [`Cef::root_cache_path`] to separate +/// the two if that matters. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SecretStorage { + /// Skip the OS secret store in development builds (`tauri::is_dev()`) and use it in + /// release builds: `--use-mock-keychain` on macOS and `--password-store=basic` on + /// Linux, in development only. Windows is untouched and keeps using DPAPI. + #[default] + Auto, + /// Always encrypt with Chromium's hard-coded constant: `--use-mock-keychain` on macOS, + /// `--password-store=basic` on Linux. Windows is untouched and keeps using DPAPI. + /// + /// Read the security note on [`SecretStorage`] before shipping this in a release + /// build: the key is a public constant, so the cookie jar is effectively unprotected. + Mock, + /// Always use the operating system secret store, on every platform and in every build + /// profile. Appends no switch at all. + System, +} + +/// What to do with Chromium's process sandbox. +/// +/// Defaults to [`SandboxPolicy::Auto`], which keeps the sandbox wherever the runtime can. +/// +/// # Windows does not have a sandbox here yet +/// +/// **Whatever this policy says, a Windows build currently runs unsandboxed.** CEF wants a +/// sandbox broker pointer that, since Chromium M138, only a binary built with Chromium's +/// own toolchain can create; CEF supplies prebuilt `bootstrap.exe` hosts for that, and +/// they load the application as a DLL, which a Tauri application is not. Given a null +/// broker CEF sets `no_sandbox` itself, so there is no configuration here that changes +/// the outcome — only whether the runtime warns about it ([`Self::Auto`]) or refuses to +/// start ([`Self::Required`]). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SandboxPolicy { + /// Keep the sandbox wherever it can be kept, and log a warning naming the reason + /// wherever it cannot. + /// + /// It cannot be kept in two situations: on Windows, always, for the reason above; and + /// on Linux or BSD when the application runs from an AppImage on a system that has + /// neither the setuid `chrome-sandbox` helper nor usable unprivileged user namespaces, + /// where the alternative is not an unsandboxed application but no application at all, + /// since Chromium aborts with "No usable sandbox!". + /// + /// macOS always keeps it. + #[default] + Auto, + /// Never run without a sandbox: fail startup instead. + /// + /// Pick this when running unsandboxed is not an acceptable outcome and a hard failure + /// is preferable. On Linux the user can then install the setuid helper, point + /// `CHROME_DEVEL_SANDBOX` at one, or re-enable unprivileged user namespaces; on Windows + /// there is nothing they can do, so this always fails there. + Required, + /// Always run without a sandbox, on every platform. + /// + /// Every renderer then runs with the full privileges of the user, so a compromised + /// renderer is a compromised account. Useful for containers and CI images that cannot + /// provide a sandbox, not for shipped applications. + Disabled, +} + +/// Whether Chromium's DevTools protocol server is reachable, and how. +/// +/// This is the server behind `chrome://inspect`, not the DevTools window: it drives the +/// browser from outside the process, so anything that can reach it can read and rewrite +/// every page the application shows. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum RemoteDebugging { + /// No server. The runtime additionally pins Chromium's + /// `devtools.remote_debugging.allowed` preference off, so a `--remote-debugging-port` + /// or `--remote-debugging-pipe` that reaches Chromium another way is refused too. + #[default] + Disabled, + /// Listen on a TCP port, as `--remote-debugging-port` does. + /// + /// The port must be between 1024 and 65535; CEF ignores anything else. The port number + /// is also written to `DevToolsActivePort` in the cache directory. + /// + /// Chromium accepts a WebSocket connection from `localhost` and from any origin named + /// in `allowed_origins` (`--remote-allow-origins`); leave that empty unless a browser + /// page has to attach. + /// + /// A listening port is reachable by every process on the machine, and the protocol has + /// no authentication. Prefer [`Self::Pipe`] where the debugger is a child process. + Port { + /// TCP port to listen on. + port: u16, + /// Origins allowed to open a WebSocket connection, beyond `localhost`. + allowed_origins: Vec, + }, + /// Speak the protocol over inherited file descriptors instead of a socket, as + /// `--remote-debugging-pipe` does. + /// + /// Reachable only by the process that launched this one, so it exposes nothing to the + /// rest of the machine. + Pipe, +} + +/// Whether this application may open a DevTools window at all. +/// +/// Application-wide, and combined with the per-webview `WebviewAttributes::devtools`: +/// either one saying no is a no. Both are enforced on the paths this runtime owns — the +/// context menu entries, the F12 and Ctrl+Shift+I chords, the `IDC_DEV_TOOLS` commands +/// and `Webview::open_devtools`. +/// +/// # Why it is not Chromium's own preference +/// +/// Chromium has a profile preference for exactly this, `devtools.availability`, and +/// `DevToolsWindow::AllowDevToolsFor` consults it on every path that opens a DevTools +/// window — including the ones this runtime does not own, such as a Chrome-owned popup. +/// This policy deliberately does not reach for it, and the runtime pins it to its +/// default instead. +/// +/// CEF gates more than the window on that preference: `SendDevToolsMessage` is refused on +/// a profile carrying `kDisallowed`, and refused *silently* — the send still reports +/// success, and no result or event ever reaches a registered observer. This runtime +/// drives its own startup over the DevTools protocol (the document-start scripts, the +/// per-webview user agent) and holds each webview's first navigation until that round +/// trip answers, so setting the preference leaves every window stuck on a blank +/// placeholder. A webview's `on_dev_tools_protocol` would go silent with it. +/// +/// So the DevTools *protocol* stays available whatever this policy says. An application +/// that has to close that path too wants [`RemoteDebugging`], which is what exposes the +/// protocol to anything outside the process. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DevToolsPolicy { + /// Allow DevTools in a build that could open them anyway — a debug build, or one with + /// the `devtools` feature — and refuse them otherwise. + #[default] + Auto, + /// Allow DevTools, whatever the build profile. + /// + /// Per-webview `WebviewAttributes::devtools` still applies; this only stops the runtime + /// from refusing DevTools application-wide. + Allowed, + /// Refuse DevTools, whatever the build profile. + Disallowed, +} + +/// What to do about a navigation to a server whose TLS certificate does not validate. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CertificateErrorPolicy { + /// Show Chrome's SSL interstitial, which offers the user a way to proceed anyway. + /// + /// This is Chromium's own behaviour and the runtime's default, because an application + /// that loads third-party content — an OAuth or SSO flow is the standing case — behaves + /// the way the user's browser would. + #[default] + ChromeInterstitial, + /// Cancel the request. No interstitial, and no way for the user to override. + /// + /// The hardened choice for an application that only ever loads origins it controls: + /// there, a certificate error is either a misconfiguration or an interception, and + /// neither is something to let a user click through. + Cancel, +} + +/// How Chromium resolves the proxy for every request. +/// +/// Written as the Chromium `proxy` preference, the same one the `ProxySettings` +/// enterprise policy sets. `WebviewAttributes::proxy_url` sets the same preference for one +/// webview; whichever is applied last to a given request context wins. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProxyConfig { + /// Use the operating system's proxy configuration. + /// + /// Chromium's default, and on Linux the one that reads `http_proxy` and its siblings + /// out of the environment. + #[default] + System, + /// Connect directly, ignoring any system proxy. + Direct, + /// Discover a proxy through WPAD. + AutoDetect, + /// Fetch a proxy auto-config script from `url`. + PacScript { + /// URL of the PAC script. + url: String, + }, + /// Use a fixed proxy. + FixedServers { + /// Proxy server, as `scheme://host:port` — for example `socks5://127.0.0.1:9050`. + /// A bare `host:port` means HTTP. + server: String, + /// Semicolon-delimited hosts that bypass the proxy, as the `--proxy-bypass-list` + /// switch spells them. + bypass_list: Option, + }, +} + +impl ProxyConfig { + /// The `proxy` preference value Chromium expects for this configuration. + fn to_preference(&self) -> serde_json::Value { + match self { + Self::System => serde_json::json!({ "mode": "system" }), + Self::Direct => serde_json::json!({ "mode": "direct" }), + Self::AutoDetect => serde_json::json!({ "mode": "auto_detect" }), + Self::PacScript { url } => serde_json::json!({ "mode": "pac_script", "pac_url": url }), + Self::FixedServers { + server, + bypass_list, + } => { + let mut value = serde_json::json!({ "mode": "fixed_servers", "server": server }); + if let Some(bypass_list) = bypass_list + && let Some(object) = value.as_object_mut() + { + object.insert( + "bypass_list".to_string(), + serde_json::Value::String(bypass_list.clone()), + ); + } + value + } + } + } +} + +/// When a page may start playing media on its own. +/// +/// Applied through Chromium's `--autoplay-policy` switch. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AutoplayPolicy { + /// Chromium's own default, which on desktop requires the user to have interacted with + /// the document before audible media plays. + #[default] + Default, + /// Let a page play media without any user interaction. + /// + /// What a kiosk, a media player or a signage application wants, and what makes a + /// hostile page able to make noise on its own. + NoUserGestureRequired, + /// Require a gesture on the media element itself. + UserGestureRequired, + /// Require the user to have interacted with the document. + DocumentUserActivationRequired, +} + +impl AutoplayPolicy { + /// The `--autoplay-policy` value, or [`None`] to leave the switch off. + fn as_switch_value(self) -> Option<&'static str> { + match self { + Self::Default => None, + Self::NoUserGestureRequired => Some("no-user-gesture-required"), + Self::UserGestureRequired => Some("user-gesture-required"), + Self::DocumentUserActivationRequired => Some("document-user-activation-required"), + } + } +} + +/// Which local network interfaces WebRTC may reveal to a page. +/// +/// Applied through Chromium's `--webrtc-ip-handling-policy` switch. Chromium's default +/// already hides local IP addresses behind mDNS hostnames, so this only matters for an +/// application that wants to go further. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum WebRtcIpHandling { + /// Chromium's default. + #[default] + Default, + /// Offer both public and private interfaces, which reveals the machine's LAN address. + DefaultPublicAndPrivateInterfaces, + /// Offer only the public interface. + DefaultPublicInterfaceOnly, + /// Refuse any UDP that does not go through the configured proxy. The strongest of the + /// four, and the one most likely to break a call outright. + DisableNonProxiedUdp, +} + +impl WebRtcIpHandling { + /// The `--webrtc-ip-handling-policy` value, or [`None`] to leave the switch off. + fn as_switch_value(self) -> Option<&'static str> { + match self { + Self::Default => None, + Self::DefaultPublicAndPrivateInterfaces => Some("default_public_and_private_interfaces"), + Self::DefaultPublicInterfaceOnly => Some("default_public_interface_only"), + Self::DisableNonProxiedUdp => Some("disable_non_proxied_udp"), + } + } +} + +/// 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)>, + disabled_features: Vec, + enabled_features: Vec, + deep_link_schemes: Vec, + cache_path: Option, + api_version: Option, + secret_storage: SecretStorage, + profile_preferences: Vec<(String, serde_json::Value)>, + global_preferences: Vec<(String, serde_json::Value)>, + content_settings: Vec<(cef::ContentSettingTypes, cef::ContentSettingValues)>, + allow_chromium_command_line_args: bool, + log_file: Option, + log_severity: Option, + log_items: Option, + locale: Option, + accept_language_list: Option, + user_agent: Option, + user_agent_product: Option, + javascript_flags: Option, + chrome_policy_id: Option, + persist_session_cookies: bool, + remote_debugging: RemoteDebugging, + devtools: DevToolsPolicy, + debug_environment: DebugEnvironment, + certificate_errors: CertificateErrorPolicy, + sandbox: SandboxPolicy, + 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("disabled_features", &self.disabled_features) + .field("enabled_features", &self.enabled_features) + .field("deep_link_schemes", &self.deep_link_schemes) + .field("cache_path", &self.cache_path) + .field("api_version", &self.api_version) + .field("secret_storage", &self.secret_storage) + .field("profile_preferences", &self.profile_preferences) + .field("global_preferences", &self.global_preferences) + .field("content_settings", &self.content_settings) + .field( + "allow_chromium_command_line_args", + &self.allow_chromium_command_line_args, + ) + .field("log_file", &self.log_file) + .field("log_severity", &self.log_severity) + .field("log_items", &self.log_items) + .field("locale", &self.locale) + .field("accept_language_list", &self.accept_language_list) + // The user agent can carry an application identifier but nothing secret; the + // JavaScript flags and policy id are likewise plain configuration. + .field("user_agent", &self.user_agent) + .field("user_agent_product", &self.user_agent_product) + .field("javascript_flags", &self.javascript_flags) + .field("chrome_policy_id", &self.chrome_policy_id) + .field("persist_session_cookies", &self.persist_session_cookies) + .field("remote_debugging", &self.remote_debugging) + .field("devtools", &self.devtools) + .field("debug_environment", &self.debug_environment) + .field("certificate_errors", &self.certificate_errors) + .field("sandbox", &self.sandbox) + .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. + /// + /// The argument is applied to the **browser process only**. CEF warns that modifying + /// the command line of a non-browser process "may result in undefined behavior + /// including crashes", and Chromium already forwards to each child process the + /// switches it needs. + #[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. + /// + /// Like [`Self::command_line_arg`], these are applied to the browser process only. + #[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 + } + + /// Which key Chromium uses to encrypt cookies and saved passwords at rest. + /// + /// Defaults to [`SecretStorage::Auto`]: development builds skip the OS secret store — + /// the macOS keychain prompt is replaced by a mock keychain, and Linux skips the D-Bus + /// secret portal, libsecret and KWallet — while release builds use it. Windows keeps + /// using DPAPI throughout. + /// + /// Nothing but cookies and saved passwords is affected. The mock keychain and the + /// Linux `basic` store encrypt with a hard-coded, publicly known constant, so reach + /// for [`SecretStorage::Mock`] only when a release build has to run where no secret + /// store exists at all. Switching between key sources makes previously stored cookies + /// unreadable. See [`SecretStorage`] for the details. + #[must_use] + pub fn secret_storage(mut self, storage: SecretStorage) -> Self { + self.secret_storage = storage; + self + } + + /// Sets one boolean Chromium profile preference on every webview's request context. + /// + /// Applied after the runtime's own defaults, so it can turn a preference the runtime + /// disabled back on as well as turn something else off. Calling it twice for the same + /// preference keeps the last value. + /// + /// The runtime disables a handful of Chrome browser features that have no place in an + /// application webview — the "Save password?" and address and credit-card bubbles + /// (`credentials_enable_service`, `profile.password_manager_leak_detection`, + /// `autofill.profile_enabled`, `autofill.credit_card_enabled`), the translate bubble + /// (`translate.enabled`), and two background requests to Google + /// (`alternate_error_pages.enabled`, `search.suggest_enabled`). An application that + /// wants one of them names it here. + /// + /// Safe Browsing (`safebrowsing.enabled`) is left on by the runtime; an application + /// whose webview only ever loads its own content can switch it off here. + /// + /// Preference names are Chromium's own, and which ones a given Chrome build registers + /// as writable varies. A preference this build refuses is logged at debug and skipped. + /// + /// ```no_run + /// # use tauri_runtime_cef::Cef; + /// Cef::default() + /// .profile_preference("credentials_enable_service", true) + /// .profile_preference("safebrowsing.enabled", false); + /// ``` + #[must_use] + pub fn profile_preference>(mut self, name: K, enabled: bool) -> Self { + self + .profile_preferences + .push((name.into(), serde_json::Value::Bool(enabled))); + self + } + + /// Sets one Chromium profile preference of any type on every webview's request context. + /// + /// [`Self::profile_preference`] covers the common boolean case; this one takes the + /// integers, strings, lists and dictionaries the rest of Chromium's preferences are made + /// of. The value must have the type Chromium registered the preference with — a string + /// where an integer belongs is refused and logged at debug. + /// + /// Preferences worth knowing about, beyond the ones with a typed option of their own: + /// + /// | Preference | Type | What it does | + /// |---|---|---| + /// | `printing.enabled` | bool | whether `window.print()` opens Chrome's print preview | + /// | `download.default_directory` | string | where downloads land | + /// | `download.prompt_for_download` | bool | whether every download asks first | + /// | `enable_do_not_track` | bool | sends the `DNT` header | + /// | `enable_referrers` | bool | sends `Referer` at all | + /// | `dns_over_https.mode` | string | `off`, `automatic` or `secure` | + /// | `dns_over_https.templates` | string | space-delimited DoH server templates | + /// | `profile.cookie_controls_mode` | int | `1` blocks third-party cookies | + /// | `hardware.audio_capture_enabled` | bool | a hard kill switch for the microphone | + /// | `hardware.video_capture_enabled` | bool | the same for the camera | + /// + /// ```no_run + /// # use tauri_runtime_cef::Cef; + /// Cef::default() + /// .profile_preference_value("dns_over_https.mode", "secure") + /// .profile_preference_value("profile.cookie_controls_mode", 1); + /// ``` + #[must_use] + pub fn profile_preference_value, V: Into>( + mut self, + name: K, + value: V, + ) -> Self { + self.profile_preferences.push((name.into(), value.into())); + self + } + + /// Sets one preference in Chromium's local state, the store shared by every profile. + /// + /// Most preferences belong to a profile and are set with + /// [`Self::profile_preference_value`]; a handful — `devtools.remote_debugging.allowed` + /// and `hardware_acceleration_mode.enabled` among them — live here instead. Applied once + /// the CEF context is initialized. + #[must_use] + pub fn global_preference, V: Into>( + mut self, + name: K, + value: V, + ) -> Self { + self.global_preferences.push((name.into(), value.into())); + self + } + + /// Sets the default value of one Chromium content setting for every origin. + /// + /// A content setting is the stored answer behind a permission: with a default of + /// [`ContentSettingValues::BLOCK`](cef::ContentSettingValues::BLOCK) a page cannot ask + /// at all, and with [`ALLOW`](cef::ContentSettingValues::ALLOW) it is granted without a + /// prompt. That makes this the application-wide policy that + /// `WebviewAttributes::on_permission_request` is not: the handler answers one request at + /// a time, this decides what can be requested. + /// + /// Applied to every webview's request context after it initializes, and stored in the + /// profile, so it also governs the origins the user has already answered for. + /// + /// Chromium refuses to write a default onto an off-the-record profile, so this has no + /// effect on a webview built with `WebviewAttributes::incognito`. Such a webview keeps + /// Chromium's own defaults, and `WebviewAttributes::on_permission_request` is the way to + /// answer for it. + /// + /// ```no_run + /// # use tauri_runtime_cef::Cef; + /// use tauri_runtime_cef::cef::{ContentSettingTypes, ContentSettingValues}; + /// + /// Cef::default() + /// // An application webview has no business asking for these. + /// .default_content_setting(ContentSettingTypes::NOTIFICATIONS, ContentSettingValues::BLOCK) + /// .default_content_setting(ContentSettingTypes::GEOLOCATION, ContentSettingValues::BLOCK) + /// // Device access a desktop application rarely wants a page to reach. + /// .default_content_setting(ContentSettingTypes::USB_GUARD, ContentSettingValues::BLOCK) + /// .default_content_setting(ContentSettingTypes::SERIAL_GUARD, ContentSettingValues::BLOCK) + /// .default_content_setting(ContentSettingTypes::HID_GUARD, ContentSettingValues::BLOCK); + /// ``` + /// + /// Blocking [`JAVASCRIPT_JIT`](cef::ContentSettingTypes::JAVASCRIPT_JIT) is worth + /// knowing about separately: it runs V8 without its optimizing compilers, which removes + /// the largest single source of exploitable memory bugs in a renderer at a real cost in + /// JavaScript performance. It is the same lever as Chrome's `DefaultJavaScriptJitSetting` + /// policy. + #[must_use] + pub fn default_content_setting( + mut self, + content_type: cef::ContentSettingTypes, + value: cef::ContentSettingValues, + ) -> Self { + self.content_settings.push((content_type, value)); + self + } + + /// Lets Chromium read switches off the process command line in release builds. + /// + /// Release builds ignore them by default (`Settings::command_line_args_disabled`), + /// because otherwise anyone who can start the shipped executable can also start it + /// with `--remote-debugging-port` and drive the app over the DevTools protocol, or + /// with `--disable-web-security`, `--proxy-server`, `--host-resolver-rules` or + /// `--ssl-key-log-file` — Chromium honours every one of them. Development builds + /// (`tauri::is_dev()`) always keep the command line enabled. + /// + /// Enable this only if the application genuinely needs users to pass Chromium + /// switches. It is not a complete lockdown either way: the network service reads the + /// `SSLKEYLOGFILE` environment variable regardless of this setting. + /// + /// Switches configured through [`Self::command_line_arg`] are unaffected, because CEF + /// clears Chromium's command line before applying its own settings and before calling + /// `on_before_command_line_processing`. Tauri's own CLI parsing and its cold-start deep + /// link handling read `std::env::args()`, which Chromium never touches, and are + /// unaffected too. Deep links delivered to an *already running* instance do go through + /// Chromium's process singleton, so the runtime restores the deep link URL onto the + /// cleared command line to keep them working. + #[must_use] + pub fn allow_chromium_command_line_args(mut self, allow: bool) -> Self { + self.allow_chromium_command_line_args = allow; + self + } + + /// File Chromium and CEF write their log to (`Settings::log_file`). + /// + /// Defaults to `cef.log` inside the cache directory (see [`Self::root_cache_path`]). + /// With no log file configured, CEF writes a `debug.log` into the *main executable + /// directory* on Windows and Linux, which for an installed application is often not + /// even writable. + /// + /// The default also overrides the macOS convention of + /// `~/Library/Logs/_debug.log`; pass that path explicitly to keep it. + #[must_use] + pub fn log_file>(mut self, path: P) -> Self { + self.log_file = Some(path.as_ref().to_path_buf()); + self + } + + /// Lowest severity Chromium and CEF write to the log file (`Settings::log_severity`). + /// + /// Defaults to [`cef::LogSeverity::WARNING`] in release builds — CEF's own default is + /// `INFO`, which is chatty enough to grow the log file of a long-running application — + /// and to [`cef::LogSeverity::DEFAULT`] in development builds (`tauri::is_dev()`), + /// where the informational messages are usually what you want. + /// + /// [`cef::LogSeverity::DISABLE`] does not turn logging off entirely: CEF maps it to a + /// FATAL-only minimum level, so nothing is written to the log file but FATAL messages + /// still go to stderr. + #[must_use] + pub fn log_severity(mut self, severity: LogSeverity) -> Self { + self.log_severity = Some(severity); + self + } + + /// Locale Chromium loads its own localized resources for (`Settings::locale`), + /// as an ISO language code such as `en-US` or `pt-BR`. + /// + /// Leave unset — the default — unless you know the matching pak file ships with the + /// application. Tauri's bundler packages **only the `en-US` locale pak**, so naming any + /// other locale leaves Chromium unable to load the localized strings it uses for its + /// own UI (context menus, error pages, form controls). This does not affect the + /// application's own content, nor which languages a website is asked for — that is + /// [`Self::accept_language_list`]. + #[must_use] + pub fn locale>(mut self, locale: S) -> Self { + self.locale = Some(locale.into()); + self + } + + /// Comma-delimited list of languages sent as the `Accept-Language` header and reported + /// through `navigator.language` (`Settings::accept_language_list`), for example + /// `en-US,en,pt-BR`. + /// + /// Defaults to CEF's own value, which is derived from [`Self::locale`]. + #[must_use] + pub fn accept_language_list>(mut self, languages: S) -> Self { + self.accept_language_list = Some(languages.into()); + self + } + + /// What to do with Chromium's process sandbox. + /// + /// Defaults to [`SandboxPolicy::Auto`], which keeps the sandbox except when the + /// application runs from an AppImage on a Linux or BSD system that offers no way to + /// sandbox at all — AppImages cannot ship the setuid `chrome-sandbox` helper the deb + /// and rpm bundlers install, and distributions such as Ubuntu 23.10 and later restrict + /// the unprivileged user namespaces Chromium would otherwise fall back to. Without the + /// escape hatch Chromium aborts at startup with "No usable sandbox!". + /// + /// See [`SandboxPolicy`] for the other variants. + #[must_use] + pub fn sandbox(mut self, policy: SandboxPolicy) -> Self { + self.sandbox = policy; + self + } + + /// Adds names to Chromium's `--disable-features` list, keeping what is already there. + /// + /// Use this rather than `command_line_arg("disable-features", ...)`. Chromium stores a + /// switch by name and the last value appended replaces the previous one, so a raw + /// `--disable-features` does not add to the list — it *becomes* the list, dropping the + /// entries CEF put there to keep Chrome from crashing at startup and to keep renderers + /// from being killed on the runtime's own requests. + #[must_use] + pub fn disable_features>( + mut self, + features: impl IntoIterator, + ) -> Self { + self + .disabled_features + .extend(features.into_iter().map(Into::into)); + self + } + + /// Adds names to Chromium's `--enable-features` list, keeping what is already there. + /// + /// See [`Self::disable_features`] for why the raw switch is the wrong tool. + #[must_use] + pub fn enable_features>(mut self, features: impl IntoIterator) -> Self { + self + .enabled_features + .extend(features.into_iter().map(Into::into)); + self + } + + /// Whether Chromium and CEF may read their diagnostic environment variables. + /// + /// Chromium honours `SSLKEYLOGFILE` — which writes the keys that decrypt every TLS + /// session the application makes — and CEF honours three variables that redirect crash + /// reports, whose minidumps carry process memory. Neither group is reachable through a + /// CEF setting, so each is refused where Chromium reads it: the key log through an + /// empty `--ssl-key-log-file`, which Chromium consults ahead of the variable, and the + /// crash overrides by taking them out of the environment before CEF starts. + /// + /// Defaults to [`DebugEnvironment::Auto`]: honoured in development builds + /// (`tauri::is_dev()`), refused in release builds. + #[must_use] + pub fn debug_environment(mut self, policy: DebugEnvironment) -> Self { + self.debug_environment = policy; + self + } + + /// Whether Chromium's DevTools protocol server runs, and how it is reached. + /// + /// Defaults to [`RemoteDebugging::Disabled`], which additionally pins Chromium's + /// `devtools.remote_debugging.allowed` preference off so the server is refused even if + /// the switch reaches Chromium another way. + /// + /// See [`RemoteDebugging`] for what each transport exposes. + #[must_use] + pub fn remote_debugging(mut self, remote_debugging: RemoteDebugging) -> Self { + self.remote_debugging = remote_debugging; + self + } + + /// Whether this application may open a DevTools window at all. + /// + /// Defaults to [`DevToolsPolicy::Auto`], which refuses them in a build that could not + /// open them anyway. See [`DevToolsPolicy`] for how this combines with the per-webview + /// `WebviewAttributes::devtools`, and for why it leaves the DevTools protocol alone. + #[must_use] + pub fn devtools(mut self, policy: DevToolsPolicy) -> Self { + self.devtools = policy; + self + } + + /// What to do about a navigation to a server whose TLS certificate does not validate. + /// + /// Defaults to [`CertificateErrorPolicy::ChromeInterstitial`], which is Chromium's own + /// behaviour: an interstitial the user can click through. + #[must_use] + pub fn certificate_errors(mut self, policy: CertificateErrorPolicy) -> Self { + self.certificate_errors = policy; + self + } + + /// How Chromium resolves the proxy for every request. + /// + /// Defaults to [`ProxyConfig::System`], Chromium's own behaviour. + #[must_use] + pub fn proxy(mut self, proxy: ProxyConfig) -> Self { + self + .profile_preferences + .push(("proxy".to_string(), proxy.to_preference())); + self + } + + /// When a page may start playing media on its own. + #[must_use] + pub fn autoplay(mut self, policy: AutoplayPolicy) -> Self { + if let Some(value) = policy.as_switch_value() { + self + .command_line_args + .push(("--autoplay-policy".to_string(), Some(value.to_string()))); + } + self + } + + /// Which local network interfaces WebRTC may reveal to a page. + #[must_use] + pub fn webrtc_ip_handling(mut self, policy: WebRtcIpHandling) -> Self { + if let Some(value) = policy.as_switch_value() { + self.command_line_args.push(( + "--webrtc-ip-handling-policy".to_string(), + Some(value.to_string()), + )); + } + self + } + + /// Whether Chromium's spell checker runs. + /// + /// On by default. The first use of a language downloads its dictionary from Google's + /// `redirector.gvt1.com`, which is the only reason an application that never shows an + /// editable field might want it off. The remote spelling *service*, which would send + /// typed text to Google, is off either way. + #[must_use] + pub fn spell_checking(mut self, enabled: bool) -> Self { + self.profile_preferences.push(( + "browser.enable_spellchecking".to_string(), + serde_json::Value::Bool(enabled), + )); + self + } + + /// Whether Chromium's Safe Browsing protection runs. + /// + /// On by default, and worth keeping on for any webview that loads content the + /// application does not control — an OAuth or SSO flow, an embedded third-party page. + /// Standard protection checks a locally stored hash-prefix database rather than calling + /// Google per navigation, and keeping it updated is the periodic request an application + /// that only ever loads its own content might want to be rid of. + #[must_use] + pub fn safe_browsing(mut self, enabled: bool) -> Self { + self.profile_preferences.push(( + "safebrowsing.enabled".to_string(), + serde_json::Value::Bool(enabled), + )); + self + } + + /// Whether Chromium's component updater runs. + /// + /// On by default, and it is the mechanism that keeps the certificate revocation set, + /// the Certificate Transparency log list and the download file-type policies current — + /// security data that goes stale. Turn it off only for a deployment that has no route + /// to `update.googleapis.com` at all. + #[must_use] + pub fn component_updates(mut self, enabled: bool) -> Self { + if !enabled { + self + .command_line_args + .push(("--disable-component-update".to_string(), None)); + } + self + } + + /// Value returned as the `User-Agent` header and `navigator.userAgent`, for every + /// webview in the process (`CefSettings.user_agent`). + /// + /// Replacing the whole string drops the Chrome and platform tokens sites branch on, so + /// prefer [`Self::user_agent_product`], which keeps them. A single webview can override + /// this through `WebviewAttributes::user_agent`. + #[must_use] + pub fn user_agent>(mut self, user_agent: S) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + /// Product token spliced into Chromium's own User-Agent string, such as `MyApp/1.2.0` + /// (`CefSettings.user_agent_product`). + /// + /// Ignored when [`Self::user_agent`] is set. + #[must_use] + pub fn user_agent_product>(mut self, product: S) -> Self { + self.user_agent_product = Some(product.into()); + self + } + + /// Whether session cookies survive a restart (`CefSettings.persist_session_cookies`). + /// + /// Off by default, matching a browser: a session cookie is dropped when the application + /// exits. A desktop application that should keep users signed in across restarts wants + /// this on, and should know that it writes those cookies to the cache directory, where + /// they are only as protected as [`SecretStorage`] makes them. + #[must_use] + pub fn persist_session_cookies(mut self, persist: bool) -> Self { + self.persist_session_cookies = persist; + self + } + + /// Flags passed to V8 (`CefSettings.javascript_flags`), such as + /// `--max-old-space-size=512`. + /// + /// Use this rather than `command_line_arg("js-flags", ...)`, which replaces the value + /// CEF derives from this setting instead of adding to it. + #[must_use] + pub fn javascript_flags>(mut self, flags: S) -> Self { + self.javascript_flags = Some(flags.into()); + self + } + + /// Enables Chrome policy management, reading policies from the platform location this + /// identifier names (`CefSettings.chrome_policy_id`). + /// + /// The identifier is a registry key on Windows (`SOFTWARE\\Policies\\Vendor\\App`), a + /// bundle identifier on macOS, and a directory on Linux (`/etc/opt/vendor/app/policies`). + /// Set it for an application deployed by an IT department that has to configure it + /// centrally; leave it unset otherwise, since it lets whoever controls that location + /// change the application's behaviour. + #[must_use] + pub fn chrome_policy_id>(mut self, policy_id: S) -> Self { + self.chrome_policy_id = Some(policy_id.into()); + self + } + + /// Which fields CEF prepends to each line of the log file (`CefSettings.log_items`). + /// + /// Defaults to CEF's own choice. + #[must_use] + pub fn log_items(mut self, items: LogItems) -> Self { + self.log_items = Some(items); + 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, @@ -102,6 +1140,19 @@ pub(crate) struct RuntimeContext { /// [`cef::initialize`]. Per-webview `data_directory` profiles must resolve /// under this root for CEF request contexts to be accepted. pub(crate) cache_path: Arc, + /// Chromium profile preferences the application asked for, applied to every + /// webview's request context after the runtime's own defaults. See + /// [`Cef::profile_preference`]. + pub(crate) profile_preferences: Arc>, + /// Default content settings the application asked for, applied to every webview's + /// request context. See [`Cef::default_content_setting`]. + pub(crate) content_settings: Arc>, + /// What to do about a navigation whose TLS certificate does not validate. See + /// [`Cef::certificate_errors`]. + pub(crate) certificate_errors: CertificateErrorPolicy, + /// Whether [`Cef::devtools`] lets this application open DevTools at all. Combined with + /// the per-webview `WebviewAttributes::devtools`, which can only narrow it further. + pub(crate) devtools_allowed: bool, } /// Scoped access to the current winit callback state. @@ -115,8 +1166,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 +1207,7 @@ impl Default for MainThreadDispatchSlot { } } -pub(crate) struct MainThreadDispatchGuard { +struct MainThreadDispatchGuard { context: RuntimeContext, dispatch: Box>, previous: *mut MainThreadDispatch, @@ -172,20 +1222,49 @@ impl Drop for MainThreadDispatchGuard { } } +/// Whether handling `message` ends in the application's `RunEvent` callback. +/// +/// Those messages must never be handled inline by [`handle_main_thread_message`]: see +/// the deadlock it documents. +fn dispatches_run_event(message: &Message) -> bool { + match message { + Message::UserEvent(_) | Message::Opened(_) => true, + #[cfg(target_os = "macos")] + Message::Reopen { .. } => true, + _ => false, + } +} + #[allow(clippy::result_large_err)] fn handle_main_thread_message( context: &RuntimeContext, message: Message, ) -> std::result::Result<(), Message> { + // A message that reaches the application's run callback goes through the event loop + // even on the main thread, because the main thread may be several frames deep inside + // a callback already — anything that spins a nested loop, such as muda's GTK4 context + // menu, gets here from the closure it is blocking on. Running the callback from there + // re-enters the application while it holds locks meant for one pass of the loop: + // `Window::popup_menu` reaches the runtime through a plugin command, which holds + // Tauri's plugin store lock across the popup and blocks a worker on the main thread, + // while `on_event_loop_event` takes that same lock to deliver the menu event — + // deadlocking both threads. Queueing keeps the callback where the loop can only be in + // one pass at a time, and matches what the wry runtime's proxy does. + if dispatches_run_event(&message) { + return Err(message); + } + let Some(dispatch) = context.current_dispatch.current() else { 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 +1276,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 +1338,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 +1381,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 +1408,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")] @@ -347,6 +1423,80 @@ pub(crate) enum EventLoopMessage { HideApplication, } +#[derive(Debug)] +pub(crate) struct WinitDragDropState { + id: DataTransferId, + paths: Option>, + paths_requested: bool, + enter_position: Option>, + latest_position: Option>, + enter_emitted: bool, + drop_pending: bool, +} + +impl WinitDragDropState { + fn position(&self) -> PhysicalPosition { + self + .latest_position + .or(self.enter_position) + .unwrap_or_default() + } +} + +fn pending_native_drag_enter( + native_drag_drop: &mut Option, +) -> Option { + native_drag_drop.as_mut().and_then(|state| { + if state.enter_emitted { + return None; + } + + let paths = state.paths.clone()?; + let position = state.position(); + + state.enter_emitted = true; + Some(DragDropEvent::Enter { paths, position }) + }) +} + +fn pending_native_drag_drop( + native_drag_drop: &mut Option, +) -> Option { + native_drag_drop.as_mut().and_then(|state| { + if !state.drop_pending || !state.enter_emitted { + return None; + } + + let paths = state.paths.clone()?; + let position = state.position(); + + Some(DragDropEvent::Drop { paths, position }) + }) +} + +fn request_native_drag_paths( + event_loop: &dyn ActiveEventLoop, + native_drag_drop: &mut Option, +) { + let Some(state) = native_drag_drop else { + return; + }; + let id = state.id; + if state.paths.is_some() || state.paths_requested { + return; + } + + if event_loop + .fetch_data_transfer(id, &TypeHint::UriList) + .is_err() + { + *native_drag_drop = None; + let _ = event_loop.set_valid_dnd_actions(id, &[]); + } else if let Some(state) = native_drag_drop { + state.paths_requested = true; + } +} + macro_rules! event_loop_getter { ($self:ident, $variant:ident) => {{ let (tx, rx) = mpsc::channel(); @@ -401,9 +1551,15 @@ fn is_cef_helper_process() -> bool { pub(crate) struct AppState { pub(crate) windows: HashMap, + /// Windows that are already closed as far as the application is concerned, + /// kept alive only so their native handle outlives the CEF browsers they + /// host. See [`WinitCefApp::close_window`]. + pub(crate) closing_windows: Vec, 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,67 +1568,29 @@ 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, receiver, state: AppState { windows: HashMap::new(), + closing_windows: Vec::new(), 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 +1602,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 +1625,102 @@ 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); + } + } else { + // The webview belonged to a window that is already closing: its + // registry entries went with `close_window`, and the native window is + // only being held open for CEF. This acknowledgement is what releases + // it, once it is the last browser the window was hosting. + for appwindow in &mut self.state.closing_windows { + appwindow + .children + .retain(|child| child.webview_id != webview_id); + } + self + .state + .closing_windows + .retain(|appwindow| !appwindow.children.is_empty()); } 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. + // + // Closing windows are searched too: they hold their children until CEF + // acknowledges them, and this destruction is what makes CEF do that. + if let Some(child) = self + .state + .windows + .values() + .chain(self.state.closing_windows.iter()) + .flat_map(|appwindow| appwindow.children.iter()) + .find(|child| child.webview_id == webview_id) + { + child.destroy_host_window(); + } } Message::CreateWindow { window_id, @@ -564,6 +1754,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 +1771,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 +1804,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 +1868,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; @@ -812,12 +1936,14 @@ impl WinitCefApp { 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. + // Every close path funnels through here, and this is the last point at which + // the window can still be named: the maps below are what `emit_window_event` + // and winit's own `Destroyed` both resolve a window through, and winit + // reports the destruction only once the window below has been dropped. + // Without this, `WindowEvent::Destroyed` never reaches the application, and + // it is what Tauri unregisters a window on — so a window closed while others + // stay open would keep its label taken and keep appearing in `Manager`'s + // window list forever. if !self.state.exiting { self.emit_window_event(window_id, WindowEvent::Destroyed); } @@ -833,8 +1959,34 @@ 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(); + // DevTools is a browser of its own, living in a window CEF owns and + // parents to this one. Closing the browser it inspects does not take it + // down first, so ask for it explicitly — exactly what a webview-level + // close does — instead of leaving CEF to discover its window is gone. + child.host.close_dev_tools(); child.host.close_browser(1); } + + if appwindow.children.is_empty() { + // Nothing is left to close, so the native window goes now. + drop(appwindow); + } else { + // `close_browser` only *starts* the close: CEF still has to tear down the + // browser's own child window, and it reports that back through + // `on_before_close`. Destroying the native window we parented it to before + // that point pulls the ground out from under a browser Chromium is still + // compositing — with DevTools attached the surface outlives the window + // long enough for the GPU process to fault on it and restart. So keep the + // window alive until `BrowserClosed` accounts for every child, and only + // hide it here: the application already saw `Destroyed`, so the wait must + // not be visible to the user. + appwindow.window.set_visible(false); + self.state.closing_windows.push(appwindow); + } + + // A window still waiting on its browsers holds `live_browsers` above zero, + // so this cannot exit the loop out from under a close in flight. self.exit_if_done(event_loop); } @@ -870,23 +2022,37 @@ impl WinitCefApp { event: WindowEvent::CloseRequested { signal_tx: tx }, }); - if !matches!(rx.try_recv(), Ok(true)) { - self.close_window(window_id, event_loop); - } + if !matches!(rx.try_recv(), Ok(true)) { + self.close_window(window_id, event_loop); + } + } + + 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 +2070,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; } @@ -913,29 +2095,6 @@ impl WinitCefApp { event_loop.exit(); } } - - /// 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. - #[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" - ))] - fn service_glib(&self, event_loop: &dyn ActiveEventLoop) { - let context = gtk::glib::MainContext::default(); - 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, - )); - } } impl ApplicationHandler for WinitCefApp { @@ -964,7 +2123,7 @@ impl ApplicationHandler for WinitCefApp { fn about_to_wait(&mut self, event_loop: &dyn ActiveEventLoop) { let _guard = self.install_current_dispatch(event_loop); - // TODO: remove once migrated to winit-gtk4 + self.apply_pending_activations(); #[cfg(any( target_os = "linux", target_os = "dragonfly", @@ -972,8 +2131,7 @@ impl ApplicationHandler for WinitCefApp { target_os = "netbsd", target_os = "openbsd" ))] - self.service_glib(event_loop); - self.sync_delegated_focus(); + self.apply_pending_host_layouts(); self.run_callback(RunEvent::MainEventsCleared); } @@ -994,11 +2152,10 @@ impl ApplicationHandler for WinitCefApp { match event { 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). - self.close_window(window_id, event_loop); - } + // Reached only when the native window went away without a close request of + // its own; `close_window` emits `Destroyed` for every path, this one + // included. + WinitWindowEvent::Destroyed => self.close_window(window_id, event_loop), WinitWindowEvent::SurfaceResized(size) => { webview::layout_app_window(appwindow); self.emit_window_event(window_id, WindowEvent::Resized(size)); @@ -1025,71 +2182,230 @@ 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)); } - WinitWindowEvent::DragEntered { paths, position } => { - let event = DragDropEvent::Enter { paths, position }; - self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + #[cfg(windows)] + WinitWindowEvent::RedrawRequested => { + appwindow.draw_background_surface(); } - WinitWindowEvent::DragMoved { position } => { - let event = DragDropEvent::Over { position }; - self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + WinitWindowEvent::DragEntered { id, position } => { + let has_file_paths = event_loop + .data_transfer(id) + .map(|data_transfer| data_transfer.has_type(&TypeHint::UriList)) + .unwrap_or(false); + + if has_file_paths { + appwindow.native_drag_drop = Some(WinitDragDropState { + id, + paths: None, + paths_requested: false, + enter_position: position, + latest_position: position, + enter_emitted: false, + drop_pending: false, + }); + let _ = event_loop.set_valid_dnd_actions(id, &[DndAction::Copy]); + request_native_drag_paths(event_loop, &mut appwindow.native_drag_drop); + } else { + appwindow.native_drag_drop = None; + let _ = event_loop.set_valid_dnd_actions(id, &[]); + } } - WinitWindowEvent::DragDropped { paths, position } => { - let event = DragDropEvent::Drop { paths, position }; - self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + WinitWindowEvent::DragPosition { id, position, .. } => { + if let Some(state) = appwindow + .native_drag_drop + .as_mut() + .filter(|state| state.id == id) + { + state.latest_position = Some(position); + state.enter_position.get_or_insert(position); + } + + let enter_event = pending_native_drag_enter(&mut appwindow.native_drag_drop); + + let over_event = appwindow + .native_drag_drop + .as_ref() + .filter(|state| state.id == id && state.enter_emitted) + .map(|_| DragDropEvent::Over { position }); + + if let Some(event) = enter_event { + self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + } + if let Some(event) = over_event { + self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + } } - WinitWindowEvent::DragLeft { .. } => { - self.emit_window_event(window_id, WindowEvent::DragDrop(DragDropEvent::Leave)); + WinitWindowEvent::DragDropped { id, .. } => { + if let Some(state) = appwindow + .native_drag_drop + .as_mut() + .filter(|state| state.id == id) + { + state.drop_pending = true; + } + + request_native_drag_paths(event_loop, &mut appwindow.native_drag_drop); + + let enter_event = pending_native_drag_enter(&mut appwindow.native_drag_drop); + let drop_event = pending_native_drag_drop(&mut appwindow.native_drag_drop); + + let drop_emitted = drop_event.is_some(); + if drop_emitted { + appwindow.native_drag_drop = None; + } + + if let Some(event) = enter_event { + self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + } + if let Some(event) = drop_event { + self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + } } - #[cfg(windows)] - WinitWindowEvent::RedrawRequested => { - appwindow.draw_background_surface(); + WinitWindowEvent::DragLeft { id } => { + let entered = appwindow + .native_drag_drop + .as_ref() + .is_some_and(|state| state.id == id && state.enter_emitted); + + appwindow.native_drag_drop = None; + + if entered { + self.emit_window_event(window_id, WindowEvent::DragDrop(DragDropEvent::Leave)); + } + } + WinitWindowEvent::DataTransferReceived { id, value, .. } => { + let mut reject_drag = false; + + if let Some(state) = appwindow + .native_drag_drop + .as_mut() + .filter(|state| state.id == id) + { + match value.try_as_file_paths() { + Ok(paths) if !paths.is_empty() => state.paths = Some(paths), + Ok(_) => reject_drag = state.drop_pending, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => reject_drag = state.drop_pending, + } + } + + if reject_drag { + appwindow.native_drag_drop = None; + let _ = event_loop.set_valid_dnd_actions(id, &[]); + return; + } + + let enter_event = pending_native_drag_enter(&mut appwindow.native_drag_drop); + let drop_event = pending_native_drag_drop(&mut appwindow.native_drag_drop); + + let drop_emitted = drop_event.is_some(); + if drop_emitted { + appwindow.native_drag_drop = None; + } + + if let Some(event) = enter_event { + self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + } + if let Some(event) = drop_event { + self.emit_window_event(window_id, WindowEvent::DragDrop(event)); + } } _ => {} } } } -/// Registers the config-listed tauri custom protocol schemes with Chromium. +/// Picks the deep link URLs out of a process command line. +/// +/// An argument qualifies when it parses as a URL whose scheme is one of `schemes`, +/// matched the same exact way `BrowserProcessHandler::on_already_running_app_relaunch` +/// matches it on the receiving end. Everything else is dropped: the point of +/// [`Cef::allow_chromium_command_line_args`] being off is that no other argument +/// survives onto Chromium's command line. +fn deep_link_arguments(args: I, schemes: &[String]) -> Vec +where + I: IntoIterator, +{ + args + .into_iter() + .filter(|arg| { + url::Url::parse(arg).is_ok_and(|url| schemes.iter().any(|scheme| scheme == url.scheme())) + }) + .collect() +} + +/// Appends `args` to `command_line`, as a switch with a value, a bare switch or a +/// positional argument depending on how each entry looks. /// -/// 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); +/// A bare name with no value is only recognised as a switch when it is spelled with its +/// `--` prefix; without one it is a positional argument. This runtime's own entries are +/// therefore all spelled `--switch`, values included — Chromium strips the prefix off the +/// key it stores, so both spellings reach the same switch. +fn append_command_line_args(command_line: &mut CommandLine, args: &[(String, Option)]) { + for (arg, value) in args { + if let Some(value) = value { + command_line.append_switch_with_value( + Some(&CefString::from(arg.as_str())), + Some(&CefString::from(value.as_str())), + ); + } else if arg.starts_with("-") { + command_line.append_switch(Some(&CefString::from(arg.as_str()))); + } else { + command_line.append_argument(Some(&CefString::from(arg.as_str()))); + } } } -wrap_app! { +wrap_with_args! { + wrap_app => TauriCefAppArgs; + struct TauriCefApp { context: RuntimeContext, context_initialized: Arc, deep_link_schemes: Vec, - command_line_args: Vec<(String, Option)>, + // Whether the deep link URL this process was launched with has to be put back + // onto Chromium's command line. See `on_before_command_line_processing`. + restore_deep_link_arguments: bool, + // Switches applied whatever process type `on_before_command_line_processing` reports. + // + // Deliberately tiny: `cef_app_t::on_before_command_line_processing` warns that + // "modifying the command-line arguments for non-browser processes may result in + // undefined behavior including crashes", so only switches we know a child process + // must see itself belong here. + internal_command_line_args: Vec<(String, Option)>, + // Switches applied only when the reported process type is the browser one. + // + // Chromium already forwards to each child the switches it needs, so anything that + // is only read in the browser process - and everything the embedding application + // supplied through `Cef::command_line_arg` - goes here. The application's own + // switches are appended last so they win over the runtime's defaults. + browser_command_line_args: Vec<(String, Option)>, + // Names merged into `--disable-features` and `--enable-features` rather than + // appended over them. + // + // CEF fills `--disable-features` before it calls this hook with a list that keeps + // Chrome from crashing at startup and renderers from being killed on the runtime's + // own requests, and Chromium's command line replaces a switch value rather than + // extending it. See `crate::switches::append_merged_switch`. + disabled_features: Vec, + enabled_features: Vec, } 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()) } @@ -1104,93 +2420,63 @@ wrap_app! { fn on_before_command_line_processing( &self, - _process_type: Option<&CefString>, + process_type: Option<&CefString>, command_line: Option<&mut CommandLine>, ) { - if let Some(command_line) = command_line { - for (arg, value) in &self.command_line_args { - if let Some(value) = value { - command_line.append_switch_with_value( - Some(&CefString::from(arg.as_str())), - Some(&CefString::from(value.as_str())), - ); - } else if arg.starts_with("-") { - command_line.append_switch(Some(&CefString::from(arg.as_str()))); - } else { - command_line.append_argument(Some(&CefString::from(arg.as_str()))); + let Some(command_line) = command_line else { + return; + }; + + append_command_line_args(command_line, &self.internal_command_line_args); + + // The browser process is the one launched without a `--type` switch, so CEF hands + // us an empty (or absent) process type for it. + let is_browser_process = process_type.is_none_or(|ty| ty.to_string().is_empty()); + if is_browser_process { + // A second launch of an already-running application is a browser process too, + // so `Settings::command_line_args_disabled` clears its command line before + // Chromium's process singleton relays it to the first instance, losing the + // `myapp://...` URL. Putting it back here happens after CEF's clear and before + // the singleton. Only deep links are restored; every other argument stays + // dropped, which is the point of the lockdown. + if self.restore_deep_link_arguments { + for deep_link in deep_link_arguments(std::env::args().skip(1), &self.deep_link_schemes) { + command_line.append_argument(Some(&CefString::from(deep_link.as_str()))); } } - } - } - } -} -/// 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) -} + append_command_line_args(command_line, &self.browser_command_line_args); -#[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, + // Last, and merging rather than replacing: CEF has already written its own + // `--disable-features` by this point, and appending over it would drop entries + // Chrome needs to start at all. + crate::switches::append_merged_switch( + command_line, + "disable-features", + &self.disabled_features, + ); + crate::switches::append_merged_switch( + command_line, + "enable-features", + &self.enabled_features, + ); + } } } - 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(); - #[cfg(all(target_os = "macos", feature = "sandbox"))] - let _sandbox = { + // A helper the browser process launched with `--no-sandbox` must not enter the sandbox + // here: the browser dropped it deliberately, and entering it anyway would only make the + // library load below fail. + #[cfg(target_os = "macos")] + let _sandbox = (!crate::sandbox::launched_without_sandbox()).then(|| { let mut sandbox = cef::sandbox::Sandbox::new(); sandbox.initialize(args.as_main_args()); sandbox - }; + }); #[cfg(target_os = "macos")] let _loader = { @@ -1212,10 +2498,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 +2537,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 +2581,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 +2645,7 @@ impl RuntimeHandle for CefRuntimeHandle { } } -pub struct CefRuntime { +pub struct CefRuntime { event_loop: EventLoop, receiver: Receiver>, context: RuntimeContext, @@ -1433,7 +2727,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`. @@ -1453,16 +2747,15 @@ impl CefRuntime { #[cfg(target_os = "macos")] let (_sandbox, _loader) = { - #[cfg(feature = "sandbox")] - let sandbox = if is_helper { + // As in `run_cef_helper_process`: only a helper enters the sandbox, and only when + // the browser process that launched it did not already drop the sandbox. + let sandbox = if is_helper && !crate::sandbox::launched_without_sandbox() { let mut sandbox = cef::sandbox::Sandbox::new(); sandbox.initialize(args.as_main_args()); Some(sandbox) } else { None }; - #[cfg(not(feature = "sandbox"))] - let sandbox = (); let loader = cef::library_loader::LibraryLoader::new(&std::env::current_exe().unwrap(), is_helper); @@ -1479,7 +2772,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,59 +2799,251 @@ 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(); + let Cef { + command_line_args, + disabled_features, + enabled_features, + deep_link_schemes, + cache_path: cache_path_override, + secret_storage, + mut profile_preferences, + mut global_preferences, + content_settings, + allow_chromium_command_line_args, + log_file, + log_severity, + log_items, + locale, + accept_language_list, + user_agent, + user_agent_product, + javascript_flags, + chrome_policy_id, + persist_session_cookies, + remote_debugging, + devtools: devtools_policy, + debug_environment, + certificate_errors, + sandbox: sandbox_policy, + settings_callback, + // Already applied, above, before the first CEF call. + api_version: _, + } = runtime_args.runtime_init_attrs; + + // CEF reads its crash-reporter overrides from `BasicStartupComplete`, which + // `cef::initialize` below reaches, and every child process inherits this environment. + // `SSLKEYLOGFILE` is answered further down, on the command line. + crate::environment::remove_crash_reporter_overrides(debug_environment, tauri::is_dev()); + + // The application's own switches are the only ones that reach Chromium in a release + // build, so a switch that turns off a security boundary got there deliberately — + // say so rather than silently obeying. + crate::switches::warn_about_dangerous_switches(&command_line_args); + crate::switches::warn_about_replacing_switches(&command_line_args); + + // Switches every process gets, and switches only the browser process gets. See the + // `TauriCefApp` fields for why the split exists. + #[allow(unused_mut)] + let mut internal_command_line_args: Vec<(String, Option)> = Vec::new(); + #[allow(unused_mut)] + let mut browser_command_line_args: Vec<(String, Option)> = Vec::new(); + + // `os_crypt` only ever runs in the browser process, so these are browser-only + // switches. See `SecretStorage` for what each one costs. + #[cfg(target_os = "macos")] + { + let mock_keychain = match secret_storage { + SecretStorage::Auto => tauri::is_dev(), + SecretStorage::Mock => true, + SecretStorage::System => false, + }; + if mock_keychain { + browser_command_line_args.push(("--use-mock-keychain".to_string(), None)); + } + } + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + { + // `basic` skips the D-Bus secret portal, libsecret and KWallet key providers, any + // of which can block startup on a keyring-unlock dialog or fail outright in a + // headless session. `Auto` splits the same way it does for the macOS keychain. + let basic_password_store = match secret_storage { + SecretStorage::Auto => tauri::is_dev(), + SecretStorage::Mock => true, + SecretStorage::System => false, + }; + if basic_password_store { + browser_command_line_args.push(("--password-store".to_string(), Some("basic".to_string()))); + } + } - // 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)); + // One decision on every platform, so a lost sandbox is always something the policy + // asked for and is always logged. On Linux and the BSDs the policy is also weighed + // against the system, because Chromium aborts with "No usable sandbox!" when its + // zygote host finds neither usable unprivileged user namespaces nor the setuid + // `chrome-sandbox` helper, so an AppImage on a system that restricts namespaces + // cannot start at all. + let no_sandbox = { + let decision = crate::sandbox::resolve_sandbox_decision(sandbox_policy); + match decision { + crate::sandbox::SandboxDecision::Keep => false, + crate::sandbox::SandboxDecision::Disable(reason) => { + log::warn!( + "running Chromium without a sandbox: {}. A compromised renderer process runs with the full privileges of the current user.", + reason.message() + ); + true + } + crate::sandbox::SandboxDecision::Refuse(reason) => { + log::error!( + "refusing to start: SandboxPolicy::Required asked for a Chromium sandbox, but {}.", + reason.message() + ); + return Err(Error::CreateWebview( + format!( + "SandboxPolicy::Required cannot be honored: {}", + reason.message() + ) + .into(), + )); + } + } + }; + // Windows encrypts with DPAPI, which needs no switch and prompts for nothing. + #[cfg(windows)] + let _ = secret_storage; + + // The DevTools gate, applied to every path this runtime owns: the context menu + // entries, the F12 and Ctrl+Shift+I chords, the `IDC_DEV_TOOLS` commands and + // `Webview::open_devtools`. See `DevToolsPolicy` for why it stops there. + let devtools_allowed = match devtools_policy { + DevToolsPolicy::Auto => cfg!(debug_assertions) || cfg!(feature = "devtools"), + DevToolsPolicy::Allowed => true, + DevToolsPolicy::Disallowed => false, + }; - let cache_path = cef_config.cache_path.clone().unwrap_or_else(|| { - let cache_base = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); - cache_base.join(&cef_config.identifier).join("cef") - }); - let _ = create_dir_all(&cache_path); + // Never the disallowing value, whatever the policy says: CEF gates + // `SendDevToolsMessage` on this same preference, and refuses it silently — the send + // reports success and no result or event is ever delivered. This runtime drives its + // own startup over the DevTools protocol (the document-start scripts, the per-webview + // user agent) and defers each webview's first navigation until that round trip + // answers, so a profile carrying `kDisallowed` leaves every window stuck on its blank + // placeholder. + // + // Written as the default rather than simply left alone because Chromium persists it + // in the profile on disk: a profile an earlier version wrote `kDisallowed` into stays + // broken on every later run, in a debug build as much as a release one, until + // something writes it back. + profile_preferences.insert( + 0, + ( + crate::cef_impl::preferences::DEVTOOLS_AVAILABILITY.to_string(), + crate::cef_impl::preferences::DEVTOOLS_ALLOWED.into(), + ), + ); - // 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(), - )); + // The DevTools protocol server drives the browser from outside the process, so it is + // refused two ways: the switch is only appended when the application asked for it, + // and the local-state preference is pinned off otherwise so a switch that reaches + // Chromium another way is refused as well. + let remote_debugging_enabled = match &remote_debugging { + RemoteDebugging::Disabled => false, + RemoteDebugging::Pipe => { + browser_command_line_args.push(("--remote-debugging-pipe".to_string(), None)); + true + } + RemoteDebugging::Port { + port, + allowed_origins, + } => { + // Chromium ignores a port below 1024, which would otherwise leave the application + // believing it had a debugger it does not have. + if *port < 1024 { + log::warn!( + "ignoring RemoteDebugging::Port {{ port: {port} }}: only ports between 1024 and 65535 are accepted" + ); + false + } else { + browser_command_line_args.push(( + "--remote-debugging-port".to_string(), + Some(port.to_string()), + )); + if !allowed_origins.is_empty() { + browser_command_line_args.push(( + "--remote-allow-origins".to_string(), + Some(allowed_origins.join(",")), + )); + } + log::warn!( + "the Chrome DevTools protocol is listening on port {port}. Anything that can \ + reach it can read and rewrite every page this application shows." + ); + true + } + } + }; + // Chromium consults `SSLKEYLOGFILE` only when this switch is absent, and an empty + // value creates no key logger, so this is how the variable is refused without writing + // to the process environment. Appended before the application's own switches so an + // application that deliberately passes `--ssl-key-log-file` still wins. + if crate::environment::neutralizes_tls_key_log(debug_environment, tauri::is_dev()) { + browser_command_line_args.push(("--ssl-key-log-file".to_string(), Some(String::new()))); } - #[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(); + // Pinned off whenever no transport was actually configured, including the rejected + // port above: `RemoteDebuggingServer` consults this before it starts a server for + // either transport, so it also refuses a switch that reaches Chromium another way. + if !remote_debugging_enabled { + global_preferences.insert( + 0, + ( + crate::cef_impl::preferences::REMOTE_DEBUGGING_ALLOWED.to_string(), + serde_json::Value::Bool(false), + ), + ); } + 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(&runtime_args.identifier).join("cef") + }); + let _ = create_dir_all(&cache_path); + + // Force X11 usage on Linux. + // + // Applied to every process type rather than only the browser one: it is not certain + // that Chromium propagates `ozone-platform` to the GPU process, and getting it wrong + // there breaks rendering outright. #[cfg(any( + target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd" ))] { - command_line_args.push(("ozone-platform".to_string(), Some("x11".to_string()))); - event_loop_builder.with_x11(); + internal_command_line_args.push(("--ozone-platform".to_string(), Some("x11".to_string()))); + // CEF integration below uses XIDs for child windows/reparenting, so GDK must not honor an + // inherited `GDK_BACKEND=wayland`. `set_allowed_backends` alone is not enough: GDK reads + // `GDK_BACKEND` first and only intersects it with the allowed list, so an inherited + // `wayland` would leave no backend to open a display with. + // + // SAFETY: `std::env::set_var` is only unsafe because another thread may be reading the + // environment concurrently. This runs during runtime initialization, before any GTK, CEF or + // Tauri thread that could read it has been spawned. Note the value is inherited by child + // processes the app spawns later, which is intended for CEF's own subprocesses. + unsafe { std::env::set_var("GDK_BACKEND", "x11") }; + gtk::gdk::set_allowed_backends("x11"); + event_loop_builder.with_gtk4(); + + // the GTK pointers this runtime hands out are GTK 4 objects, whichever bindings the `tauri` + // crate was compiled against. + tauri_runtime::gtk::declare_version(tauri_runtime::gtk::Version::V4); } #[cfg(windows)] @@ -1585,14 +3074,40 @@ impl CefRuntime { app_wide_theme: Default::default(), cef_pump, cache_path: Arc::new(cache_path.clone()), + profile_preferences: Arc::new(profile_preferences), + content_settings: Arc::new(content_settings), + certificate_errors, + devtools_allowed, }; - let mut app = TauriCefApp::new( - context.clone(), - context_initialized.clone(), + internal_command_line_args.push(("--no-first-run".to_string(), None)); + + // Appended last so an application switch overrides a runtime default with the same + // name: Chromium's command line keeps the last value appended for a given switch. + browser_command_line_args.extend(command_line_args); + + // Shipped applications ignore Chromium switches passed on their own command line: + // otherwise anyone able to launch the app can also launch it with + // `--remote-debugging-port` and drive it over the DevTools protocol, or with + // `--disable-web-security`, `--proxy-server`, `--host-resolver-rules` or + // `--ssl-key-log-file`, all of which Chromium honours. CEF clears the command line + // before applying `Settings` and before calling `on_before_command_line_processing`, + // so the switches this runtime and the application configure still take effect, and + // Tauri's own CLI parsing reads `std::env::args()`, which Chromium never touches. + // The clear does break the *relaunch* deep link path, which + // `TauriCefApp::on_before_command_line_processing` restores. + let command_line_args_disabled = !(allow_chromium_command_line_args || tauri::is_dev()); + + let mut app = TauriCefApp::build(TauriCefAppArgs { + context: context.clone(), + context_initialized: context_initialized.clone(), deep_link_schemes, - command_line_args, - ); + restore_deep_link_arguments: command_line_args_disabled, + internal_command_line_args, + browser_command_line_args, + disabled_features, + enabled_features, + }); // Subprocesses already exited above, so this must be the browser process; // `execute_process` returns -1 there to signal normal startup should follow. @@ -1606,35 +3121,76 @@ 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; + // Chromium drops a `debug.log` next to the *main executable* when no log file is + // configured, which for an installed application is often not even writable. Keep it + // next to the rest of the runtime's state instead. + let log_file = log_file.unwrap_or_else(|| cache_path.join("cef.log")); + // CEF logs at INFO by default, which grows that file quickly in a long-running app. + let log_severity = log_severity.unwrap_or(if tauri::is_dev() { + LogSeverity::DEFAULT + } else { + LogSeverity::WARNING + }); - let settings = cef::Settings { - no_sandbox: !cfg!(feature = "sandbox") as i32, + let mut settings = cef::Settings { + // Only this, never a `--no-sandbox` push of our own: CEF appends that switch itself + // from the setting, before `on_before_command_line_processing` runs, so the setting + // and the switch cannot end up disagreeing. + no_sandbox: no_sandbox as std::os::raw::c_int, 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(), + command_line_args_disabled: command_line_args_disabled as std::os::raw::c_int, + log_file: log_file.to_string_lossy().to_string().as_str().into(), + log_severity, + persist_session_cookies: persist_session_cookies as std::os::raw::c_int, + external_message_pump: 1, ..Default::default() }; + + if let Some(log_items) = log_items { + settings.log_items = log_items; + } + + // Left at CEF's defaults unless the application asked for something else: the bundler + // only ships the `en-US` locale pak, so any other locale would leave Chromium without + // its localized resources. + if let Some(locale) = locale { + settings.locale = locale.as_str().into(); + } + + // With neither of these set CEF appends `--lang=en-US` and derives the accept-language + // list from it, so every user of every CEF application reports `navigator.language === + // "en-US"` and asks servers for English. The locale pak constraint above does not + // apply here — this is a list of language codes, not a resource bundle — so the + // runtime answers with what the user actually asked their system for. + let accept_language_list = + accept_language_list.or_else(crate::locale::system_accept_language_list); + if let Some(accept_language_list) = accept_language_list { + settings.accept_language_list = accept_language_list.as_str().into(); + } + + // `user_agent` wins over `user_agent_product` in CEF, so setting both is the + // application contradicting itself; say so rather than silently dropping one. + if let Some(user_agent) = &user_agent { + settings.user_agent = user_agent.as_str().into(); + if user_agent_product.is_some() { + log::warn!( + "ignoring the CEF user agent product: Cef::user_agent replaces the whole User-Agent string, including the product token" + ); + } + } else if let Some(user_agent_product) = &user_agent_product { + settings.user_agent_product = user_agent_product.as_str().into(); + } + + if let Some(javascript_flags) = &javascript_flags { + settings.javascript_flags = javascript_flags.as_str().into(); + } + if let Some(chrome_policy_id) = &chrome_policy_id { + settings.chrome_policy_id = chrome_policy_id.as_str().into(); + } + + if let Some(callback) = settings_callback { + callback(&mut settings); + } if cef::initialize( Some(args.as_main_args()), Some(&settings), @@ -1654,21 +3210,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; @@ -1704,6 +3245,12 @@ impl CefRuntime { std::thread::sleep(Duration::from_millis(1)); } + // Local state exists only once the context is initialized, and + // `preference_manager_get_global` has to be called on the browser UI thread — which + // is this one, since the runtime drives CEF from the main thread through an external + // message pump. + crate::cef_impl::preferences::apply_global_preferences(&global_preferences); + Ok(Self { event_loop, receiver, @@ -1720,8 +3267,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 +3284,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 +3319,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 +3333,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 +3395,219 @@ 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(test)] +mod configuration_tests { + use super::*; + + #[test] + fn a_fixed_proxy_is_spelled_the_way_chromium_spells_it() { + let preference = ProxyConfig::FixedServers { + server: "socks5://127.0.0.1:9050".to_string(), + bypass_list: Some("*.internal".to_string()), + } + .to_preference(); + + assert_eq!( + preference, + serde_json::json!({ + "mode": "fixed_servers", + "server": "socks5://127.0.0.1:9050", + "bypass_list": "*.internal", + }) + ); } -} -#[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)); + #[test] + fn a_fixed_proxy_without_a_bypass_list_omits_the_key() { + // Chromium rejects the whole `proxy` dictionary when it carries a key the mode does + // not accept, so an absent bypass list must be absent rather than empty. + let preference = ProxyConfig::FixedServers { + server: "http://proxy:8080".to_string(), + bypass_list: None, } - child + .to_preference(); + + assert_eq!( + preference, + serde_json::json!({ "mode": "fixed_servers", "server": "http://proxy:8080" }) + ); } #[test] - fn sleeper_child() { - if std::env::var_os("CEF_SINGLETON_LOCK_SLEEPER").is_some() { - std::thread::sleep(std::time::Duration::from_secs(10)); + fn the_modeless_proxy_configurations_carry_only_a_mode() { + for (config, mode) in [ + (ProxyConfig::System, "system"), + (ProxyConfig::Direct, "direct"), + (ProxyConfig::AutoDetect, "auto_detect"), + ] { + assert_eq!(config.to_preference(), serde_json::json!({ "mode": mode })); } + assert_eq!( + ProxyConfig::PacScript { + url: "http://wpad/proxy.pac".to_string() + } + .to_preference(), + serde_json::json!({ "mode": "pac_script", "pac_url": "http://wpad/proxy.pac" }) + ); + } + + #[test] + fn the_default_policies_append_no_switch_at_all() { + // Chromium's own default is not one of the named values, so `Default` has to mean + // "leave the switch off" rather than "pass the default explicitly". + assert_eq!(AutoplayPolicy::default().as_switch_value(), None); + assert_eq!(WebRtcIpHandling::default().as_switch_value(), None); + } + + #[test] + fn the_named_policies_use_chromiums_own_spelling() { + assert_eq!( + AutoplayPolicy::NoUserGestureRequired.as_switch_value(), + Some("no-user-gesture-required"), + "autoplay values are hyphenated" + ); + assert_eq!( + WebRtcIpHandling::DisableNonProxiedUdp.as_switch_value(), + Some("disable_non_proxied_udp"), + "WebRTC values are underscored" + ); + } + + #[test] + fn remote_debugging_is_off_by_default() { + assert_eq!(RemoteDebugging::default(), RemoteDebugging::Disabled); + } + + #[test] + fn the_defaults_are_the_conservative_ones() { + let cef = Cef::default(); + assert_eq!(cef.devtools, DevToolsPolicy::Auto); + assert_eq!(cef.debug_environment, DebugEnvironment::Auto); + assert_eq!(cef.sandbox, SandboxPolicy::Auto); + assert!( + !cef.allow_chromium_command_line_args, + "a shipped application must ignore Chromium switches on its command line" + ); + assert!( + !cef.persist_session_cookies, + "a session cookie is dropped on exit, as it is in a browser" + ); + assert_eq!( + cef.certificate_errors, + CertificateErrorPolicy::ChromeInterstitial + ); } #[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); + fn a_typed_option_is_just_a_preference() { + // The typed options and the escape hatch write the same store, so an application can + // reach anything the typed set does not cover. + let cef = Cef::default().safe_browsing(false).spell_checking(false); + assert!( + cef + .profile_preferences + .iter() + .any(|(name, value)| name == "safebrowsing.enabled" && value == &serde_json::json!(false)) + ); + assert!( + cef + .profile_preferences + .iter() + .any(|(name, value)| name == "browser.enable_spellchecking" + && value == &serde_json::json!(false)) + ); } #[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); + fn a_later_preference_wins_over_an_earlier_one() { + // They are applied in order, so the last one written is the one that sticks. + let cef = Cef::default() + .safe_browsing(false) + .profile_preference("safebrowsing.enabled", true); + let values: Vec<_> = cef + .profile_preferences + .iter() + .filter(|(name, _)| name == "safebrowsing.enabled") + .map(|(_, value)| value.clone()) + .collect(); + assert_eq!( + values, + [serde_json::json!(false), serde_json::json!(true)], + "both are kept, in call order, so the application's last word wins" + ); + } +} + +#[cfg(test)] +mod deep_link_argument_tests { + use super::deep_link_arguments; + + fn schemes() -> Vec { + vec!["myapp".to_string(), "my-other-app".to_string()] + } + + fn filter(args: &[&str]) -> Vec { + deep_link_arguments(args.iter().map(|arg| (*arg).to_string()), &schemes()) } #[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); + fn keeps_configured_deep_links_in_order() { + assert_eq!( + filter(&["myapp://open/one", "my-other-app://open/two"]), + vec![ + "myapp://open/one".to_string(), + "my-other-app://open/two".to_string(), + ] + ); } #[test] - fn a_live_second_instance_is_reported() { - let mut sibling = spawn_sibling(); - let dir = lock_dir(&format!("{}-{}", hostname(), sibling.id())); + fn drops_everything_that_is_not_a_configured_deep_link() { + // The lockdown exists so that none of these reach Chromium's command line, and a + // URL with an unconfigured scheme is not this application's deep link either. + assert!( + filter(&[ + "--remote-debugging-port=9222", + "--disable-web-security", + "/home/user/document.txt", + "not a url", + "", + "https://example.com", + "otherapp://open", + ]) + .is_empty() + ); + } - let holder = live_singleton_lock_holder(dir.path()); + #[test] + fn an_empty_scheme_list_keeps_nothing() { + assert!(deep_link_arguments(["myapp://open".to_string()], &[]).is_empty()); + } - let _ = sibling.kill(); - let _ = sibling.wait(); - assert_eq!(holder, Some(sibling.id())); + #[test] + fn scheme_matching_is_exact() { + // `on_already_running_app_relaunch` compares schemes the same way, so anything + // matched loosely here would be re-appended and then ignored on the other end. + // `Url::parse` lowercases the scheme it reports, hence the upper-case spelling + // below still matching. + assert_eq!(filter(&["MYAPP://open"]), vec!["MYAPP://open".to_string()]); + assert!(filter(&["myapp2://open", "myap://open"]).is_empty()); } } diff --git a/src/sandbox.rs b/src/sandbox.rs new file mode 100644 index 0000000..652fe7e --- /dev/null +++ b/src/sandbox.rs @@ -0,0 +1,539 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Decides whether Chromium's process sandbox has to be turned off. +//! +//! On every platform the answer normally comes straight from [`SandboxPolicy`], and the +//! answer is "keep it". Only Linux and the BSDs have a case where keeping it means the +//! application cannot start at all, and detecting that case is what the bulk of this +//! module is for. +//! +//! # The Linux case +//! +//! Chromium's zygote host picks, in order, the namespace sandbox when unprivileged user +//! namespaces work, then the root-owned setuid `chrome-sandbox` helper found next to the +//! executable (or at `CHROME_DEVEL_SANDBOX`). When neither is available it calls +//! `LOG(FATAL) << "No usable sandbox!"` and the process dies before a window is ever +//! shown. +//! +//! Tauri's deb and rpm bundlers install `chrome-sandbox` with mode 4755, so packaged +//! applications always have the helper. An AppImage cannot: the runtime mounts its +//! payload with `nosuid`, so a setuid binary inside it is inert. That leaves AppImages +//! relying on unprivileged user namespaces, which Ubuntu 23.10 and later restrict +//! through AppArmor — which is exactly the combination this module detects. +//! +//! Tauri's AppImage bundler does copy `chrome-sandbox` next to the main binary, so the +//! helper is *present* in every CEF AppImage and finding a file by that name proves +//! nothing; one inside an AppImage is never treated as available. Outside an AppImage +//! the file is stat'ed against the same conditions Chromium's zygote host applies — +//! owned by root, setuid, executable by others — because Chromium treats a helper that +//! fails them as a fatal error rather than falling back to another sandbox. +//! +//! # The Windows case +//! +//! **Windows currently runs unsandboxed, whatever the policy says.** Chromium's Windows +//! sandbox is brokered by the executable rather than by the library: CEF wants a +//! `sandbox_info` pointer from `cef_sandbox_info_create()` passed into both +//! `CefExecuteProcess` and `CefInitialize`, and when it gets a null one it sets +//! `CefSettings.no_sandbox` itself and appends `--no-sandbox` +//! (`libcef/browser/main_runner.cc`). This runtime passes null. +//! +//! Fixing that is a packaging change, not a code change: since Chromium M138 the sandbox +//! entry point can only be linked by a binary built with Chromium's own toolchain, so CEF +//! ships prebuilt `bootstrap.exe` / `bootstrapc.exe` hosts that load the application as a +//! DLL exporting `RunWinMain` or `RunConsoleMain` and hand it the pointer. A Tauri +//! application is built as an executable, so until it can be built and bundled as a +//! bootstrap-hosted DLL there is nothing to pass. +//! +//! Until then the honest thing is to say so: [`windows_sandbox_unavailable`] reports the +//! gap so [`SandboxPolicy::Auto`] logs it like any other lost sandbox, and +//! [`SandboxPolicy::Required`] fails loudly instead of quietly returning a promise the +//! platform cannot keep. +//! +//! # macOS +//! +//! macOS sandboxes through `libcef_sandbox.dylib`, which the helper process loads and +//! initializes before the framework, so there is nothing to probe: the policy decides on +//! its own and [`SandboxPolicy::Auto`] always keeps the sandbox. + +use crate::runtime::SandboxPolicy; + +/// Whether this platform can actually sandbox, given how the runtime initializes CEF. +/// +/// Windows cannot yet: see the module docs. Kept as a function of a `cfg` rather than a +/// `cfg` at every use site so the decision table stays testable on one platform. +pub(crate) const fn windows_sandbox_unavailable() -> bool { + cfg!(windows) +} + +/// Why the sandbox is being turned off. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SandboxDisableReason { + /// The application asked for it through [`SandboxPolicy::Disabled`]. + Policy, + /// AppImage, no setuid helper, and AppArmor restricts unprivileged user namespaces. + AppImageUserNamespacesRestricted, + /// AppImage, no setuid helper, and user namespaces are unavailable altogether. + AppImageUserNamespacesUnavailable, + /// Windows, where this runtime cannot supply CEF with a sandbox broker. + WindowsBrokerUnavailable, +} + +impl SandboxDisableReason { + /// Message logged when the sandbox is dropped for this reason. + pub(crate) fn message(self) -> &'static str { + match self { + Self::Policy => "the application set SandboxPolicy::Disabled", + Self::AppImageUserNamespacesRestricted => { + "running from an AppImage, which cannot ship the setuid chrome-sandbox helper, \ + and unprivileged user namespaces are restricted \ + (/proc/sys/kernel/apparmor_restrict_unprivileged_userns is 1)" + } + Self::AppImageUserNamespacesUnavailable => { + "running from an AppImage, which cannot ship the setuid chrome-sandbox helper, \ + and unprivileged user namespaces are unavailable \ + (/proc/sys/user/max_user_namespaces is 0)" + } + Self::WindowsBrokerUnavailable => { + "the Windows sandbox needs a broker this runtime cannot supply: CEF requires the \ + application to be hosted by its bootstrap executable as a DLL, and a Tauri \ + application is built as an executable" + } + } + } +} + +/// Whether `--no-sandbox` has to be appended, and why. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SandboxDecision { + /// Leave the sandbox alone. + Keep, + /// Append `--no-sandbox`, logging `reason`. + Disable(SandboxDisableReason), + /// [`SandboxPolicy::Required`] asked for a sandbox this platform cannot provide, so + /// startup fails rather than silently running without one. + Refuse(SandboxDisableReason), +} + +/// Decides whether to disable the sandbox, from inputs the caller has already gathered. +/// +/// Kept free of I/O so every combination can be unit tested. +/// +/// `apparmor_restrict_unprivileged_userns` and `max_user_namespaces` are the values read +/// from `/proc/sys/kernel/apparmor_restrict_unprivileged_userns` and +/// `/proc/sys/user/max_user_namespaces`; [`None`] means the file could not be read, +/// which is treated as no evidence of a restriction rather than as a restriction. +/// +/// `windows_broker_unavailable` is [`windows_sandbox_unavailable`]: on Windows CEF drops +/// the sandbox itself when the embedder hands it no broker, so the runtime cannot keep a +/// sandbox there however the policy is set. +pub(crate) fn sandbox_decision( + policy: SandboxPolicy, + windows_broker_unavailable: bool, + running_from_appimage: bool, + sandbox_helper_available: bool, + apparmor_restrict_unprivileged_userns: Option, + max_user_namespaces: Option, +) -> SandboxDecision { + if let SandboxPolicy::Disabled = policy { + return SandboxDecision::Disable(SandboxDisableReason::Policy); + } + + // CEF flips `no_sandbox` on for us when it gets a null broker, so `Keep` here would be + // a decision the platform overrules a moment later. Reporting it instead keeps the + // rule that a lost sandbox is always named out loud. + if windows_broker_unavailable { + return match policy { + SandboxPolicy::Required => { + SandboxDecision::Refuse(SandboxDisableReason::WindowsBrokerUnavailable) + } + _ => SandboxDecision::Disable(SandboxDisableReason::WindowsBrokerUnavailable), + }; + } + + match policy { + SandboxPolicy::Disabled => SandboxDecision::Disable(SandboxDisableReason::Policy), + SandboxPolicy::Required => SandboxDecision::Keep, + SandboxPolicy::Auto => { + // Everything but an AppImage can ship the setuid helper, so a missing sandbox + // there is a packaging or system problem we should not paper over. + if !running_from_appimage || sandbox_helper_available { + return SandboxDecision::Keep; + } + + if apparmor_restrict_unprivileged_userns == Some(1) { + SandboxDecision::Disable(SandboxDisableReason::AppImageUserNamespacesRestricted) + } else if max_user_namespaces == Some(0) { + SandboxDecision::Disable(SandboxDisableReason::AppImageUserNamespacesUnavailable) + } else { + SandboxDecision::Keep + } + } + } +} + +/// Whether this process was launched with Chromium's `--no-sandbox` switch. +/// +/// A child process inherits the switch from the browser process that spawned it, so this +/// is how a macOS helper learns that entering the sandbox would be wrong. It is read off +/// the real command line rather than off [`SandboxPolicy`] because a helper never sees +/// the `Cef` builder. +#[cfg(target_os = "macos")] +pub(crate) fn launched_without_sandbox() -> bool { + std::env::args().any(|arg| arg == "--no-sandbox") +} + +/// Whether a `chrome-sandbox` candidate passes the checks Chromium's zygote host makes +/// before it will use the helper, given the `st_uid` and `st_mode` a `stat` reported. +/// +/// `ZygoteHostImpl::Init` requires the file to be owned by root, to carry the setuid bit +/// and to be executable by others; a file that is there but fails any of those aborts +/// with "The SUID sandbox helper binary was found, but is not configured correctly", so +/// a half-configured helper must not count as available. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +pub(crate) fn helper_stat_is_usable(uid: u32, mode: u32) -> bool { + /// `S_ISUID`. + const SETUID: u32 = 0o4000; + /// `S_IXOTH`. + const OTHER_EXECUTE: u32 = 0o0001; + + uid == 0 && mode & SETUID != 0 && mode & OTHER_EXECUTE != 0 +} + +/// Gathers the inputs [`sandbox_decision`] needs from the environment and the filesystem. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +pub(crate) fn resolve_sandbox_decision(policy: SandboxPolicy) -> SandboxDecision { + let running_from_appimage = running_from_appimage(); + sandbox_decision( + policy, + windows_sandbox_unavailable(), + running_from_appimage, + sandbox_helper_available(running_from_appimage), + read_sysctl("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"), + read_sysctl("/proc/sys/user/max_user_namespaces"), + ) +} + +/// The policy's own answer, with nothing to probe: neither Windows nor macOS has an +/// equivalent of the AppImage case. macOS therefore keeps the sandbox under +/// [`SandboxPolicy::Auto`]; Windows cannot, for the reason in the module docs. +#[cfg(not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +)))] +pub(crate) fn resolve_sandbox_decision(policy: SandboxPolicy) -> SandboxDecision { + sandbox_decision( + policy, + windows_sandbox_unavailable(), + false, + false, + None, + None, + ) +} + +/// AppImage runtimes export `APPIMAGE` with the path of the mounted image. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +fn running_from_appimage() -> bool { + std::env::var_os("APPIMAGE").is_some_and(|path| !path.is_empty()) +} + +/// Whether Chromium can find *and use* the setuid `chrome-sandbox` helper. +/// +/// The helper next to the executable is disregarded entirely when running from an +/// AppImage: the bundler always copies `chrome-sandbox` there, and the AppImage runtime +/// mounts the payload `nosuid`, so the setuid bit `stat` still reports has no effect. +/// +/// `CHROME_DEVEL_SANDBOX` is somebody deliberately pointing at a helper outside the +/// application, so it is honoured on every layout, but it is stat'ed like any other +/// candidate. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +fn sandbox_helper_available(running_from_appimage: bool) -> bool { + if let Some(path) = std::env::var_os("CHROME_DEVEL_SANDBOX").filter(|path| !path.is_empty()) { + return helper_path_is_usable(std::path::Path::new(&path)); + } + + if running_from_appimage { + return false; + } + + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|dir| dir.join("chrome-sandbox"))) + .is_some_and(|helper| helper_path_is_usable(&helper)) +} + +/// Stats `path` and hands what it reports to [`helper_stat_is_usable`]. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +fn helper_path_is_usable(path: &std::path::Path) -> bool { + use std::os::unix::fs::MetadataExt; + + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + + let usable = helper_stat_is_usable(metadata.uid(), metadata.mode()); + if !usable { + log::debug!( + "ignoring the chrome-sandbox helper at {}: it is not a root-owned setuid binary executable by others", + path.display() + ); + } + usable +} + +/// Reads a numeric sysctl, returning [`None`] when it is missing or unparseable. +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +fn read_sysctl(path: &str) -> Option { + std::fs::read_to_string(path).ok()?.trim().parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Shorthand for the `Auto` policy, which is the only one that inspects the system, on + /// a platform whose sandbox broker works. + fn auto( + running_from_appimage: bool, + sandbox_helper_available: bool, + apparmor: Option, + max_user_namespaces: Option, + ) -> SandboxDecision { + sandbox_decision( + SandboxPolicy::Auto, + false, + running_from_appimage, + sandbox_helper_available, + apparmor, + max_user_namespaces, + ) + } + + #[test] + fn explicit_policies_ignore_the_system() { + for appimage in [false, true] { + for helper in [false, true] { + assert_eq!( + sandbox_decision( + SandboxPolicy::Disabled, + false, + appimage, + helper, + Some(1), + Some(0) + ), + SandboxDecision::Disable(SandboxDisableReason::Policy) + ); + assert_eq!( + sandbox_decision( + SandboxPolicy::Required, + false, + appimage, + helper, + Some(1), + Some(0) + ), + SandboxDecision::Keep, + "Required must keep the sandbox even when Chromium will abort" + ); + } + } + } + + #[test] + fn a_platform_without_a_broker_never_reports_a_sandbox_it_does_not_have() { + // CEF sets `no_sandbox` itself when it gets a null broker, so claiming `Keep` here + // would be a decision the platform overrules a moment later. + assert_eq!( + sandbox_decision(SandboxPolicy::Auto, true, false, false, None, None), + SandboxDecision::Disable(SandboxDisableReason::WindowsBrokerUnavailable) + ); + } + + #[test] + fn required_refuses_to_start_where_the_sandbox_cannot_be_provided() { + // The whole point of `Required` is that running unsandboxed is not an acceptable + // outcome, so it must fail rather than come up without one. + assert_eq!( + sandbox_decision(SandboxPolicy::Required, true, false, false, None, None), + SandboxDecision::Refuse(SandboxDisableReason::WindowsBrokerUnavailable) + ); + } + + #[test] + fn disabled_is_answered_before_the_platform_is_consulted() { + // An application that asked for no sandbox is told what it asked for, not what the + // platform could not give it. + assert_eq!( + sandbox_decision(SandboxPolicy::Disabled, true, false, false, None, None), + SandboxDecision::Disable(SandboxDisableReason::Policy) + ); + } + + #[test] + fn auto_keeps_the_sandbox_outside_an_appimage() { + // A deb or rpm install ships the setuid helper, and a system that lost it should + // fail loudly rather than silently run unsandboxed. + assert_eq!(auto(false, false, Some(1), Some(0)), SandboxDecision::Keep); + assert_eq!(auto(false, true, None, None), SandboxDecision::Keep); + } + + #[test] + fn auto_keeps_the_sandbox_when_the_helper_is_available() { + assert_eq!(auto(true, true, Some(1), Some(0)), SandboxDecision::Keep); + } + + #[test] + fn auto_keeps_the_sandbox_when_user_namespaces_work() { + assert_eq!( + auto(true, false, Some(0), Some(31231)), + SandboxDecision::Keep + ); + } + + #[test] + fn auto_disables_for_an_appimage_restricted_by_apparmor() { + assert_eq!( + auto(true, false, Some(1), Some(31231)), + SandboxDecision::Disable(SandboxDisableReason::AppImageUserNamespacesRestricted) + ); + } + + #[test] + fn auto_disables_for_an_appimage_without_user_namespaces() { + assert_eq!( + auto(true, false, Some(0), Some(0)), + SandboxDecision::Disable(SandboxDisableReason::AppImageUserNamespacesUnavailable) + ); + // The AppArmor sysctl only exists on kernels carrying that patch. + assert_eq!( + auto(true, false, None, Some(0)), + SandboxDecision::Disable(SandboxDisableReason::AppImageUserNamespacesUnavailable) + ); + } + + #[test] + fn unreadable_sysctls_are_not_evidence_of_a_restriction() { + // Nothing readable: assume namespaces work and let Chromium have the last word. + assert_eq!(auto(true, false, None, None), SandboxDecision::Keep); + } + + #[test] + fn apparmor_restriction_is_reported_over_a_missing_namespace_quota() { + // Both point the same way; the AppArmor one is the actionable message. + assert_eq!( + auto(true, false, Some(1), Some(0)), + SandboxDecision::Disable(SandboxDisableReason::AppImageUserNamespacesRestricted) + ); + } + + /// The platforms with nothing to probe answer from the policy alone, which is what + /// [`resolve_sandbox_decision`] passes there. Asserted everywhere so the contract + /// cannot drift on the platforms that do not compile that arm. + #[test] + fn nothing_to_probe_means_the_policy_decides() { + assert_eq!( + sandbox_decision(SandboxPolicy::Auto, false, false, false, None, None), + SandboxDecision::Keep, + "Auto must keep the sandbox where there is no AppImage case to escape" + ); + assert_eq!( + sandbox_decision(SandboxPolicy::Required, false, false, false, None, None), + SandboxDecision::Keep + ); + assert_eq!( + sandbox_decision(SandboxPolicy::Disabled, false, false, false, None, None), + SandboxDecision::Disable(SandboxDisableReason::Policy), + "Disabled is the only way for macOS to lose the sandbox" + ); + } + + /// The runtime hands CEF a null Windows sandbox broker, and CEF answers that by + /// dropping the sandbox itself. Asserted on every platform so the constant cannot drift + /// away from what `resolve_sandbox_decision` passes. + #[test] + fn the_windows_broker_is_reported_as_unavailable_only_on_windows() { + assert_eq!(windows_sandbox_unavailable(), cfg!(windows)); + } + + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + #[test] + fn a_correctly_installed_helper_is_usable() { + // Mode 4755, which is what the deb and rpm bundlers install. + assert!(helper_stat_is_usable(0, 0o104755)); + } + + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + #[test] + fn a_helper_missing_any_of_chromiums_conditions_is_not_usable() { + // Chromium aborts outright on a helper that fails these, so "present but wrong" has + // to read as unavailable. + assert!( + !helper_stat_is_usable(1000, 0o104755), + "a helper not owned by root cannot raise privileges" + ); + assert!( + !helper_stat_is_usable(0, 0o100755), + "without the setuid bit the helper runs as the calling user" + ); + assert!( + !helper_stat_is_usable(0, 0o104750), + "the helper has to be executable by others" + ); + // What `fs::copy` produces in an AppDir: right name, none of the bits. + assert!(!helper_stat_is_usable(1000, 0o100644)); + } +} diff --git a/src/switches.rs b/src/switches.rs new file mode 100644 index 0000000..c06c180 --- /dev/null +++ b/src/switches.rs @@ -0,0 +1,268 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Helpers for the Chromium command line the runtime hands to CEF. +//! +//! Two problems live here. +//! +//! # A switch value replaces, it does not accumulate +//! +//! Chromium's `CommandLine::AppendSwitchNative` stores switches in a map keyed by name, +//! so appending a switch that is already there overwrites the previous value; nothing in +//! Chrome, CEF or CEF's patches installs a `DuplicateSwitchHandler` that would merge them +//! instead. For most switches that is what an embedder wants — the runtime relies on it +//! so an application's own switch wins over a runtime default. +//! +//! For the comma-delimited ones it is a trap. CEF fills `--disable-features` before it +//! calls `on_before_command_line_processing`, with a list that keeps Chrome from crashing +//! at startup and keeps renderers from being killed on CEF's own requests, and an +//! application that appends its own `--disable-features` silently drops all of it. +//! [`append_merged_switch`] is the merging append those switches need, and +//! [`REPLACING_SWITCHES`] is the list an application is warned about when it reaches for +//! the raw API instead. +//! +//! # Some switches turn security off +//! +//! Chrome itself keeps a list of command line flags that "stability and security will +//! suffer" from and shows an infobar naming them. A shipped Tauri application ignores its +//! process command line entirely (see `Cef::allow_chromium_command_line_args`), so the +//! only way one of those flags reaches Chromium is the application putting it there. +//! [`warn_about_dangerous_switches`] says so out loud. + +/// Switches whose value Chromium or CEF has already set by the time the application's own +/// switches are appended, and which therefore replace rather than extend. +/// +/// Each entry names the API that sets the same thing without dropping what is there. +pub(crate) const REPLACING_SWITCHES: &[(&str, &str)] = &[ + ("disable-features", "Cef::disable_features"), + ("enable-features", "Cef::enable_features"), + ("js-flags", "Cef::javascript_flags"), +]; + +/// Chromium switches that turn off a security boundary, mirroring the list Chrome warns +/// about in `chrome/browser/ui/startup/bad_flags_prompt.cc`. +/// +/// Advisory only: the runtime still appends whatever the application asked for. A name +/// that a future Chromium renames simply stops matching, which costs a warning and +/// nothing else. +const DANGEROUS_SWITCHES: &[&str] = &[ + // Web platform boundaries. + "disable-web-security", + "allow-running-insecure-content", + "ignore-certificate-errors", + "ignore-certificate-errors-spki-list", + "unsafely-treat-insecure-origin-as-secure", + "allow-insecure-localhost", + "disable-site-isolation-trials", + "enable-blink-features", + "disable-blink-features", + "enable-unsafe-webgpu", + "disable-hid-blocklist", + "unsafely-allow-protected-media-identifier-for-domain", + // Process sandbox. + "no-sandbox", + "disable-gpu-sandbox", + "disable-setuid-sandbox", + "disable-seccomp-filter-sandbox", + "disable-namespace-sandbox", + "disable-landlock-sandbox", + "disable-webnn-compiler-sandbox", + "allow-sandbox-debugging", + "allow-third-party-modules", + "single-process", + // Traffic capture and redirection. + "host-resolver-rules", + "host-rules", + "ssl-key-log-file", + "log-net-log", + "net-log-capture-mode", + // Media and input. + "disable-webrtc-encryption", + "use-fake-ui-for-media-stream", + "enable-speech-dispatcher", + "enable-gpu-benchmarking", +]; + +/// Splits a comma-delimited switch value, dropping the empty entries Chromium ignores. +fn split_list(value: &str) -> impl Iterator { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) +} + +/// The value a merging append should write, given what the switch already holds. +/// +/// Returns [`None`] when there would be nothing to write, so the caller leaves the +/// command line alone rather than appending an empty switch. +/// +/// `values` entries may themselves be comma-delimited, so a caller that collected +/// `["A,B", "C"]` gets the same result as one that collected `["A", "B", "C"]`. +fn merged_switch_value(existing: &str, values: &[String]) -> Option { + let mut merged: Vec<&str> = split_list(existing).collect(); + + for value in values.iter().flat_map(|value| split_list(value)) { + if !merged.contains(&value) { + merged.push(value); + } + } + + (!merged.is_empty()).then(|| merged.join(",")) +} + +/// Merges `values` into the comma-delimited switch `name` already on `command_line`, +/// preserving what is there and skipping duplicates. +/// +/// Chromium reads the *last* value appended for a switch, and there is no API to edit one +/// in place, so the merge is read, remove, append. +pub(crate) fn append_merged_switch( + command_line: &mut cef::CommandLine, + name: &str, + values: &[String], +) { + use cef::{CefString, ImplCommandLine}; + + if values.is_empty() { + return; + } + + let switch = CefString::from(name); + let existing = if command_line.has_switch(Some(&switch)) == 1 { + CefString::from(&command_line.switch_value(Some(&switch))).to_string() + } else { + String::new() + }; + + let Some(merged) = merged_switch_value(&existing, values) else { + return; + }; + + // `append_switch_with_value` overwrites the map entry but leaves the old spelling in + // `argv`; removing first keeps the two consistent for anything that re-parses it. + command_line.remove_switch(Some(&switch)); + command_line.append_switch_with_value(Some(&switch), Some(&CefString::from(merged.as_str()))); +} + +/// Strips the `-`/`--` prefix Chromium tolerates on a switch name, so a switch spelled +/// either way is recognised. +fn switch_key(argument: &str) -> &str { + argument.trim_start_matches('-') +} + +/// Warns about application switches that replace a value the runtime or CEF depends on. +pub(crate) fn warn_about_replacing_switches(args: &[(String, Option)]) { + for (argument, _) in args { + let key = switch_key(argument); + if let Some((_, replacement)) = REPLACING_SWITCHES.iter().find(|(name, _)| *name == key) { + log::warn!( + "the --{key} switch replaces the value Chromium and CEF already set rather than \ + adding to it, which drops entries the runtime depends on. Use {replacement} instead." + ); + } + } +} + +/// Warns about application switches that turn off a security boundary. +pub(crate) fn warn_about_dangerous_switches(args: &[(String, Option)]) { + for (argument, _) in args { + let key = switch_key(argument); + if DANGEROUS_SWITCHES.contains(&key) { + log::warn!( + "the --{key} switch turns off a Chromium security boundary. Chrome itself warns \ + its users when it is set; do not ship it." + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_list_is_split_the_way_chromium_splits_it() { + let parts: Vec<_> = split_list("A,,B , C,").collect(); + assert_eq!(parts, ["A", "B", "C"]); + } + + fn merge(existing: &str, values: &[&str]) -> Option { + let values: Vec = values.iter().map(ToString::to_string).collect(); + merged_switch_value(existing, &values) + } + + #[test] + fn merging_keeps_what_cef_already_disabled() { + // The whole point: CEF's crash-avoidance entries survive the application's own. + assert_eq!( + merge("GlicActorUi,LensOverlay", &["MediaRouter"]).as_deref(), + Some("GlicActorUi,LensOverlay,MediaRouter") + ); + } + + #[test] + fn merging_onto_an_unset_switch_writes_only_the_new_values() { + assert_eq!(merge("", &["A", "B"]).as_deref(), Some("A,B")); + } + + #[test] + fn a_value_already_present_is_not_repeated() { + assert_eq!( + merge("A,B", &["B", "C", "B"]).as_deref(), + Some("A,B,C"), + "a duplicate would be harmless to Chromium but makes the switch unreadable" + ); + } + + #[test] + fn entries_may_themselves_be_comma_delimited() { + assert_eq!(merge("A", &["B,C", "D"]).as_deref(), Some("A,B,C,D")); + } + + #[test] + fn nothing_to_write_leaves_the_command_line_alone() { + // An empty switch value is not the same as an absent switch, so it must not be + // appended: `--disable-features=` reads as "disable nothing named". + assert_eq!(merge("", &[""]), None); + assert_eq!(merge("", &[]), None); + } + + #[test] + fn switch_names_are_recognised_with_or_without_a_prefix() { + assert_eq!(switch_key("--disable-features"), "disable-features"); + assert_eq!(switch_key("-disable-features"), "disable-features"); + assert_eq!(switch_key("disable-features"), "disable-features"); + } + + #[test] + fn every_replacing_switch_names_a_replacement() { + for (name, replacement) in REPLACING_SWITCHES { + assert!(!name.is_empty()); + assert!( + replacement.starts_with("Cef::"), + "the warning tells the user what to call instead" + ); + } + } + + #[test] + fn the_two_lists_answer_different_questions() { + // `disable-features` replaces CEF's crash-avoidance list, which is a correctness + // problem rather than a security one, so it is warned about but not called dangerous. + assert!( + REPLACING_SWITCHES + .iter() + .any(|(name, _)| *name == "disable-features") + ); + assert!(!DANGEROUS_SWITCHES.contains(&"disable-features")); + + // The blink feature switches turn boundaries off but overwrite nothing the runtime + // set, so they are only on the dangerous list. + assert!(DANGEROUS_SWITCHES.contains(&"enable-blink-features")); + assert!( + !REPLACING_SWITCHES + .iter() + .any(|(name, _)| *name == "enable-blink-features") + ); + } +} diff --git a/src/tauri_ext.rs b/src/tauri_ext.rs new file mode 100644 index 0000000..d3b4200 --- /dev/null +++ b/src/tauri_ext.rs @@ -0,0 +1,524 @@ +// 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, ChromeCommandGroup, ConsoleMessage, 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; + + /// Observes the messages the renderer writes to the JavaScript console, + /// without DevTools having to be open. + /// + /// The callback runs synchronously on CEF's UI thread, so it must return + /// promptly and must not wait for an event-loop operation. Observing a message + /// does not suppress it: CEF logs it as it normally would. It is scoped to this + /// webview's own native browser, so neither a CEF-owned popup's output nor that + /// of a DevTools window opened on this webview is reported here. + #[must_use] + fn on_console_message(self, handler: F) -> Self; + + /// Keeps the named families of Chrome commands rather than swallowing them. + /// + /// A Chrome style browser keeps its whole accelerator table live even hosted as a + /// child view with no browser UI, so by default this runtime swallows the commands + /// that have no meaning in an app window — new window and tab, the tab strip, + /// history, downloads and settings, print, save page, view source, the omnibox + /// focus commands. Naming a [`ChromeCommandGroup`] here lets that family run the + /// way it would in a browser. Calling this more than once replaces the previous + /// list. + /// + /// DevTools and zoom accelerators are not covered here: they follow + /// `WebviewAttributes::devtools` and `WebviewAttributes::zoom_hotkeys_enabled`. + /// + /// ```no_run + /// # use tauri_runtime_cef::{AsCefWebviewAttributes, ChromeCommandGroup}; + /// # fn f(builder: tauri::WebviewWindowBuilder<'_, R, M>) + /// # where + /// # R: tauri::Runtime, + /// # M: tauri::Manager, + /// # R::RuntimeWebviewAttributes: AsCefWebviewAttributes, + /// # { + /// use tauri_runtime_cef::WebviewWindowBuilderCefExt; + /// // Ctrl+P prints and Alt+Left goes back, as a user expects. + /// builder.allow_chrome_commands([ChromeCommandGroup::Document, ChromeCommandGroup::History]); + /// # } + /// ``` + #[must_use] + fn allow_chrome_commands>(self, groups: I) -> Self; + + /// Takes a last look at the CEF [`BrowserSettings`](cef::BrowserSettings) before the + /// browser is created. + /// + /// The runtime maps the portable `WebviewAttributes` onto these settings first, so this + /// can change what it decided as well as reach the fields Tauri has no attribute for: + /// the font families and sizes, `remote_fonts`, `local_storage`, `databases`, `webgl`, + /// `tab_to_links`, `javascript_dom_paste` and `default_encoding`. + /// + /// ```no_run + /// # use tauri_runtime_cef::AsCefWebviewAttributes; + /// # fn f(builder: tauri::WebviewWindowBuilder<'_, R, M>) + /// # where + /// # R: tauri::Runtime, + /// # M: tauri::Manager, + /// # R::RuntimeWebviewAttributes: AsCefWebviewAttributes, + /// # { + /// use tauri_runtime_cef::WebviewWindowBuilderCefExt; + /// use tauri_runtime_cef::cef::{State, sys::cef_state_t}; + /// + /// // An app that ships its own fonts has no use for the ones a page asks for. + /// builder.with_browser_settings(|settings| { + /// settings.remote_fonts = State::from(cef_state_t::STATE_DISABLED); + /// }); + /// # } + /// ``` + #[must_use] + fn with_browser_settings( + self, + callback: 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 + } + + fn on_console_message( + mut self, + handler: F, + ) -> Self { + let handler = Arc::new(handler); + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.console_message_handler = Some(handler); + }); + self + } + + fn allow_chrome_commands>( + mut self, + groups: I, + ) -> Self { + let groups = groups.into_iter().collect::>(); + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.allowed_chrome_commands = groups.clone(); + }); + self + } + + fn with_browser_settings( + mut self, + callback: F, + ) -> Self { + let callback = Arc::new(callback); + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.browser_settings_callback = Some(callback.clone()); + }); + 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; + + /// Observes the messages the renderer writes to the JavaScript console, + /// without DevTools having to be open. + /// + /// The callback runs synchronously on CEF's UI thread, so it must return + /// promptly and must not wait for an event-loop operation. Observing a message + /// does not suppress it: CEF logs it as it normally would. It is scoped to this + /// webview's own native browser, so neither a CEF-owned popup's output nor that + /// of a DevTools window opened on this webview is reported here. + #[must_use] + fn on_console_message(self, handler: F) -> Self; + + /// Keeps the named families of Chrome commands rather than swallowing them. + /// + /// A Chrome style browser keeps its whole accelerator table live even hosted as a + /// child view with no browser UI, so by default this runtime swallows the commands + /// that have no meaning in an app window — new window and tab, the tab strip, + /// history, downloads and settings, print, save page, view source, the omnibox + /// focus commands. Naming a [`ChromeCommandGroup`] here lets that family run the + /// way it would in a browser. Calling this more than once replaces the previous + /// list. + /// + /// DevTools and zoom accelerators are not covered here: they follow + /// `WebviewAttributes::devtools` and `WebviewAttributes::zoom_hotkeys_enabled`. + #[must_use] + fn allow_chrome_commands>(self, groups: I) -> Self; + + /// Takes a last look at the CEF [`BrowserSettings`](cef::BrowserSettings) before the + /// browser is created. + /// + /// The runtime maps the portable `WebviewAttributes` onto these settings first, so this + /// can change what it decided as well as reach the fields Tauri has no attribute for: + /// the font families and sizes, `remote_fonts`, `local_storage`, `databases`, `webgl`, + /// `tab_to_links`, `javascript_dom_paste` and `default_encoding`. + #[must_use] + fn with_browser_settings( + self, + callback: 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 + } + + fn on_console_message( + mut self, + handler: F, + ) -> Self { + let handler = Arc::new(handler); + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.console_message_handler = Some(handler); + }); + self + } + + fn allow_chrome_commands>( + mut self, + groups: I, + ) -> Self { + let groups = groups.into_iter().collect::>(); + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.allowed_chrome_commands = groups.clone(); + }); + self + } + + fn with_browser_settings( + mut self, + callback: F, + ) -> Self { + let callback = Arc::new(callback); + with_cef_webview_attributes(self.runtime_specific_attributes_mut(), |attributes| { + attributes.browser_settings_callback = Some(callback.clone()); + }); + self + } +} diff --git a/src/webview.rs b/src/webview.rs index f3cd17c..2269ded 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. @@ -70,8 +153,6 @@ fn color_to_argb(color: Color) -> u32 { /// /// The following Tauri webview attributes have no per-webview equivalent in CEF /// and are intentionally ignored here: -/// - `user_agent`: CEF only exposes a process-global user agent via -/// `CefSettings.user_agent`, which is fixed before any webview is created. /// - `additional_browser_args`, `scroll_bar_style`, `general_autofill_enabled`: /// WebView2 (Windows)-only concepts. /// - `allow_link_preview`, `accept_first_mouse`: WKWebView (macOS/iOS)-only. @@ -79,10 +160,13 @@ fn color_to_argb(color: Color) -> u32 { /// support in the Chrome runtime. /// - `data_store_identifier`: a WKWebView data-store concept with no CEF analog /// (per-webview isolation is done through the request context cache path). -/// - `zoom_hotkeys_enabled`: handled by Chromium's accelerator table, not a -/// browser setting. /// -/// `proxy_url` is handled separately via the request context preference. +/// `proxy_url` is handled separately via the request context preference, +/// `zoom_hotkeys_enabled` through the client's command handler, because zoom +/// reaches a browser through Chromium's accelerator table rather than through a +/// browser setting, and `user_agent` through the DevTools protocol (see +/// [`apply_user_agent_override`]), because `CefSettings.user_agent` is fixed for +/// the whole process before any webview is created. fn browser_settings_from_webview_attributes( webview_attributes: &WebviewAttributes, ) -> cef::BrowserSettings { @@ -101,17 +185,38 @@ fn browser_settings_from_webview_attributes( .background_color .map(color_to_argb) .unwrap_or(0), + // Browser chrome a Tauri window has no business showing: the status bubble is + // the link target that slides in over the bottom-left of the page on hover, + // and the zoom bubble the popup Chrome anchors to its (absent) toolbar on + // Ctrl+Plus. Both draw over the app's own UI; both are ignored under Alloy + // style. + chrome_status_bubble: cef::State::from(cef::sys::cef_state_t::STATE_DISABLED), + chrome_zoom_bubble: cef::State::from(cef::sys::cef_state_t::STATE_DISABLED), ..Default::default() } } +/// 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, @@ -120,6 +225,140 @@ pub enum DevToolsProtocol { } pub(crate) type DevToolsProtocolHandler = dyn Fn(DevToolsProtocol) + Send + Sync; + +/// A family of Chrome commands the runtime swallows in an application window. +/// +/// A Chrome style browser keeps its whole accelerator table live even when it is hosted +/// as a child view with no browser UI, so Ctrl+N opens a real Chrome window next to the +/// app's and Ctrl+P prints the app's own markup. The runtime blocks the families below by +/// default; naming one in +/// [`allow_chrome_commands`](crate::WebviewWindowBuilderCefExt::allow_chrome_commands) +/// lets that family run the way it would in a browser. +/// +/// DevTools and zoom are not here: they follow `WebviewAttributes::devtools` and +/// `WebviewAttributes::zoom_hotkeys_enabled`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ChromeCommandGroup { + /// Ctrl+N, Ctrl+Shift+N, Ctrl+T and the whole tab strip: new window, new incognito + /// window, new tab, duplicate, restore, reorder, and select tab 1-8. + /// + /// An app window has no tab strip for these to act on, and the windows they open are + /// real Chrome windows the application does not own. + WindowAndTab, + /// Ctrl+P, Ctrl+S, Ctrl+U, Ctrl+O: print, print without preview, save page, view + /// source, open file, and the PWA install and shortcut commands. + /// + /// The commonest group to want back — Ctrl+P is a keystroke users expect. Note that + /// `WebviewDispatch::print` prints on request without this, and that `IDC_OPEN_FILE` + /// and `IDC_SAVE_PAGE` raise OS file dialogs. + Document, + /// Ctrl+L and its neighbours: focus the omnibox, the search box, the toolbar, the menu + /// bar or the bookmarks bar, plus Home and open-current-URL. + /// + /// An app window has none of that chrome, so these can only move keyboard focus + /// somewhere the user cannot see; Home and open-current-URL additionally navigate the + /// webview away from the app's own UI. + BrowserChrome, + /// Ctrl+H, Ctrl+J, Ctrl+D, Ctrl+Shift+Delete and the rest: history, downloads, + /// bookmarks, settings, clear browsing data, the task manager, sign-in, about and + /// feedback. + /// + /// These load Chrome WebUI pages *in place of the app's UI*, in the very webview the + /// accelerator was pressed in, and expose the browsing data of every webview sharing + /// the request context. + BrowserSurface, + /// Alt+Left and Alt+Right: back and forward through the session history. + /// + /// The browser is created at an internal placeholder URL and only then navigated to the + /// app's own, so the app's first screen already sits on a second history entry and + /// going back from it lands on a blank page. `WebviewDispatch::go_back` and + /// `go_forward` work without this. + History, +} + +impl ChromeCommandGroup { + /// Every group, which is what the runtime blocks when a webview allows none. The + /// blocklist is resolved from this, so a variant added here is blocked by default. + pub(crate) const ALL: &'static [Self] = &[ + Self::WindowAndTab, + Self::Document, + Self::BrowserChrome, + Self::BrowserSurface, + Self::History, + ]; +} + +/// One message a renderer wrote to the JavaScript console. +/// +/// Reported synchronously on CEF's UI thread. Observing a message neither +/// suppresses CEF's own logging of it nor changes what DevTools shows. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct ConsoleMessage { + /// How severe the renderer considers the message. + pub level: ConsoleMessageLevel, + /// The message text, already formatted by the renderer the way DevTools shows + /// it. + pub message: String, + /// What wrote the message — a script URL, usually. Empty when CEF names none. + pub source: String, + /// The 1-based line in `source`. Zero when CEF names none. + pub line: i32, +} + +/// The severity of a [`ConsoleMessage`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ConsoleMessageLevel { + /// `console.debug`. + Verbose, + /// `console.log` and `console.info`. Also CEF's default severity, which it + /// documents as INFO. + Info, + /// `console.warn`. + Warning, + /// `console.error`, and messages the renderer itself reports as errors, such as + /// an uncaught exception or a blocked subresource. + Error, + /// A fatal log severity. No console API produces one. + Fatal, + /// A severity this build of the runtime does not name. + Other, +} + +/// Synchronous observer of renderer console output. +pub type ConsoleMessageHandler = dyn Fn(ConsoleMessage) + Send + Sync + 'static; + +/// Last look at a webview's [`cef::BrowserSettings`] before its browser is created. +pub type BrowserSettingsCallback = dyn Fn(&mut cef::BrowserSettings) + Send + Sync + 'static; + +impl ConsoleMessage { + pub(crate) fn from_cef( + level: cef::LogSeverity, + message: Option<&CefString>, + source: Option<&CefString>, + line: i32, + ) -> Self { + use cef::sys::cef_log_severity_t; + + Self { + level: match cef_log_severity_t::from(level) { + cef_log_severity_t::LOGSEVERITY_VERBOSE => ConsoleMessageLevel::Verbose, + cef_log_severity_t::LOGSEVERITY_DEFAULT | cef_log_severity_t::LOGSEVERITY_INFO => { + ConsoleMessageLevel::Info + } + cef_log_severity_t::LOGSEVERITY_WARNING => ConsoleMessageLevel::Warning, + cef_log_severity_t::LOGSEVERITY_ERROR => ConsoleMessageLevel::Error, + cef_log_severity_t::LOGSEVERITY_FATAL => ConsoleMessageLevel::Fatal, + _ => ConsoleMessageLevel::Other, + }, + message: message.map(ToString::to_string).unwrap_or_default(), + source: source.map(ToString::to_string).unwrap_or_default(), + line, + } + } +} pub(crate) type WebviewEventHandler = Box; pub(crate) type WebviewEventListeners = Arc>>; @@ -191,11 +430,17 @@ 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>>>, /// Keeps the DevTools message observer registered. Dropping this unregisters the observer. pub(crate) devtools_observer_registration: Arc>>, + /// Whether a DevTools window may be opened for this webview. The DevTools *protocol* + /// stays available either way — the runtime's own startup rides on it. + pub(crate) devtools_enabled: bool, pub(crate) listeners: WebviewEventListeners, pub(crate) bounds_rate: Option, } @@ -280,8 +525,10 @@ impl WinitCefApp { drag_drop_event_target: browser_client::DragDropEventTarget, pending: PendingWebview>, ) -> Result<()> { - let parent = appwindow.raw_cef_handle(); - let parent_size = appwindow.window.surface_size(); + // Windows/macOS use the native window/view; Linux uses the GTK content-area + // X11 host so CEF children cannot cover GTK UI like menus. + let parent = appwindow.cef_host_handle(); + let parent_size = appwindow.safe_surface_size(); let scale = appwindow.window.scale_factor(); let app_wide_theme = *context.app_wide_theme.lock().unwrap(); let theme = appwindow.resolved_theme(app_wide_theme); @@ -302,16 +549,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(()) } @@ -333,6 +580,25 @@ impl WinitCefApp { parent_size, scale, ); + let devtools_enabled = context.devtools_allowed + && (cfg!(debug_assertions) || cfg!(feature = "devtools")) + && pending.webview_attributes.devtools.unwrap_or(true); + + // Alloy style keeps none of Chrome's accelerator table, so the DevTools chord has + // to be scripted the way it is for every webview `tauri-runtime-wry` drives. A + // Chrome style browser must not get the script: it dispatches `IDC_DEV_TOOLS` for + // the same chord, and with both in place the toggle closes the window the + // accelerator just opened. + #[cfg(any(debug_assertions, feature = "devtools"))] + if devtools_enabled && is_alloy_style(pending.runtime_specific_attributes.runtime_style) { + pending.webview_attributes.initialization_scripts.push( + tauri_runtime::webview::InitializationScript { + script: tauri_runtime::webview::devtools_shortcut_script(), + for_main_frame_only: true, + }, + ); + } + let initialization_scripts = initialization_scripts(&mut pending.webview_attributes); let uri_scheme_protocols: Arc> = Arc::new( pending @@ -344,45 +610,75 @@ 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 zoom_hotkeys_enabled = pending.webview_attributes.zoom_hotkeys_enabled; + let allowed_chrome_commands = pending + .runtime_specific_attributes + .allowed_chrome_commands + .clone(); 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(), + console_message_handler: pending + .runtime_specific_attributes + .console_message_handler + .clone(), + permission_request_handler: pending.permission_request_handler.take().map(Arc::from), 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, + zoom_hotkeys_enabled, + allowed_chrome_commands, + 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 +687,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,17 +697,29 @@ 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; - let settings = browser_settings_from_webview_attributes(&pending.webview_attributes); + let mut settings = browser_settings_from_webview_attributes(&pending.webview_attributes); + // Applied last so an application can override what the runtime mapped. + if let Some(callback) = &pending + .runtime_specific_attributes + .browser_settings_callback + { + callback(&mut settings); + } + let settings = settings; + // CEF has no per-browser user agent — `CefSettings.user_agent` is fixed for the whole + // process before any browser exists — so the per-webview attribute is served through + // the DevTools protocol instead, which overrides both the header and + // `navigator.userAgent` for this one target. + let user_agent = pending.webview_attributes.user_agent.clone(); let custom_protocol_scheme = if pending.webview_attributes.use_https_scheme { "https" @@ -469,13 +777,27 @@ 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(), ))); + // Before the initial navigation below, so the first request already carries it. + if let Some(user_agent) = &user_agent { + apply_user_agent_override(&host, user_agent); + } load_initial_url_after_registering_initialization_scripts( &browser, &initialization_scripts, @@ -491,10 +813,14 @@ impl WinitCefApp { label, browser, browser_id, + frame_navigation_state, + popup_family, + dialogs, host, uri_scheme_protocols, devtools_protocol_handlers, devtools_observer_registration, + devtools_enabled, listeners: Default::default(), bounds_rate, }) @@ -504,6 +830,8 @@ impl WinitCefApp { let request_context = request_context::request_context_from_webview_attributes( &context.cache_path, &pending.webview_attributes, + context.profile_preferences.clone(), + context.content_settings.clone(), uri_scheme_protocols.keys(), &custom_protocol_scheme, scheme_registry.clone(), @@ -531,17 +859,190 @@ impl WinitCefApp { return; } + // Window-dependent messages must read window metrics like safe_surface_size. + // Route them before borrowing a child mutably so those parent reads do not + // overlap with the child borrow. + match message { + WebviewMessage::SetBounds(_) + | WebviewMessage::SetSize(_) + | WebviewMessage::SetPosition(_) + | WebviewMessage::Position(_) + | WebviewMessage::Size(_) + | WebviewMessage::SetAutoResize(_) + | WebviewMessage::WithWebview(_) + | WebviewMessage::Reparent(_, _) => { + self.handle_window_dependent_webview_message(window_id, webview_id, message); + } + message => { + let Some(appwindow) = self.state.windows.get_mut(&window_id) else { + return; + }; + let Some(child) = appwindow + .children + .iter_mut() + .find(|child| child.webview_id == webview_id) + else { + return; + }; + + Self::handle_window_independent_webview_message(child, message); + } + } + } + + fn handle_window_dependent_webview_message( + &mut self, + window_id: WindowId, + webview_id: u32, + message: WebviewMessage, + ) { let Some(appwindow) = self.state.windows.get_mut(&window_id) else { return; }; - let Some(child) = appwindow + let Some(child_index) = appwindow .children .iter_mut() - .find(|child| child.webview_id == webview_id) + .position(|child| child.webview_id == webview_id) else { return; }; + match message { + WebviewMessage::SetBounds(bounds) => { + let parent_size = appwindow.safe_surface_size(); + let scale = appwindow.window.scale_factor(); + let child = &mut appwindow.children[child_index]; + child.set_bounds(parent_size, scale, bounds); + } + WebviewMessage::SetSize(size) => { + let parent_size = appwindow.safe_surface_size(); + let scale = appwindow.window.scale_factor(); + let child = &mut appwindow.children[child_index]; + let bounds = child.bounds().unwrap_or_default(); + let new_bounds = Rect { + position: bounds.position, + size, + }; + child.set_bounds(parent_size, scale, new_bounds); + } + WebviewMessage::SetPosition(position) => { + let parent_size = appwindow.safe_surface_size(); + let scale = appwindow.window.scale_factor(); + let child = &mut appwindow.children[child_index]; + let bounds = child.bounds().unwrap_or_default(); + let new_bounds = Rect { + position, + size: bounds.size, + }; + child.set_bounds(parent_size, scale, new_bounds); + } + WebviewMessage::Position(tx) => { + let scale = appwindow.window.scale_factor(); + let child = &mut appwindow.children[child_index]; + let bounds = child.bounds().ok_or(Error::FailedToSendMessage); + let position = bounds.map(|b| b.position); + let position = position.map(|p| p.to_physical::(scale)); + let _ = tx.send(position); + } + WebviewMessage::Size(tx) => { + let scale = appwindow.window.scale_factor(); + let child = &mut appwindow.children[child_index]; + let bounds = child.bounds().ok_or(Error::FailedToSendMessage); + let size = bounds.map(|b| b.size.to_physical::(scale)); + let _ = tx.send(size); + } + WebviewMessage::SetAutoResize(auto_resize) => { + if auto_resize { + let parent_size = appwindow.safe_surface_size(); + let scale = appwindow.window.scale_factor(); + let child = &mut appwindow.children[child_index]; + let bounds = child.bounds(); + child.bounds_rate = compute_child_bounds_rate(bounds.as_ref(), true, parent_size, scale); + } else { + let child = &mut appwindow.children[child_index]; + child.bounds_rate = None; + } + } + WebviewMessage::Reparent(target_window_id, tx) => { + if window_id == target_window_id { + let _ = tx.send(Ok(())); + return; + } + + if !self.state.windows.contains_key(&target_window_id) { + let _ = tx.send(Err(Error::WindowNotFound)); + return; + } + + let Some(mut child) = self + .state + .windows + .get_mut(&window_id) + .and_then(|appwindow| { + appwindow + .children + .iter() + .position(|child| child.webview_id == webview_id) + .map(|index| appwindow.children.remove(index)) + }) + else { + let _ = tx.send(Err(Error::WindowNotFound)); + return; + }; + + let Some(target_appwindow) = self.state.windows.get_mut(&target_window_id) else { + let _ = tx.send(Err(Error::WindowNotFound)); + return; + }; + + // The parent size must be the area the child is laid out into, which on Linux is the + // CEF host rather than the toplevel - `layout_app_window` applies `bounds_rate` against + // the same value. + let parent_size = target_appwindow.safe_surface_size(); + let bounds = child.bounds().unwrap_or_else(|| Rect { + position: PhysicalPosition::new(0, 0).into(), + size: parent_size.into(), + }); + child.reparent(target_appwindow); + child.set_bounds(parent_size, 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(())); + } + WebviewMessage::WithWebview(callback) => { + let child = &appwindow.children[child_index]; + 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); + } + _ => unreachable!("window-independent message routed to window-dependent handler"), + } + } + + fn handle_window_independent_webview_message(child: &mut AppWebview, message: WebviewMessage) { match message { WebviewMessage::EvaluateScript(script) => { if let Some(frame) = child.browser.main_frame() { @@ -552,12 +1053,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 +1071,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,48 +1100,8 @@ 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::SetBounds(bounds) => { - let parent_size = appwindow.window.surface_size(); - let scale = appwindow.window.scale_factor(); - child.set_bounds(parent_size, scale, bounds); - } - WebviewMessage::SetSize(size) => { - let parent_size = appwindow.window.surface_size(); - let scale = appwindow.window.scale_factor(); - let bounds = child.bounds().unwrap_or_default(); - let new_bounds = Rect { - position: bounds.position, - size, - }; - child.set_bounds(parent_size, scale, new_bounds); - } - WebviewMessage::SetPosition(position) => { - let parent_size = appwindow.window.surface_size(); - let scale = appwindow.window.scale_factor(); - let bounds = child.bounds().unwrap_or_default(); - let new_bounds = Rect { - position, - size: bounds.size, - }; - child.set_bounds(parent_size, scale, new_bounds); - } - WebviewMessage::SetFocus => { - child.host.set_focus(1); - child.take_input_focus(); - } + WebviewMessage::Close => child.host.close_browser(0), + WebviewMessage::SetFocus => child.host.set_focus(1), WebviewMessage::Url(tx) => { let url = child.url().unwrap_or_default(); let _ = tx.send(Ok(url)); @@ -647,18 +1110,9 @@ impl WinitCefApp { let bounds = child.bounds().ok_or(Error::FailedToSendMessage); let _ = tx.send(bounds); } - WebviewMessage::Position(tx) => { - let bounds = child.bounds().ok_or(Error::FailedToSendMessage); - let position = bounds.map(|b| b.position); - let position = position.map(|p| p.to_physical::(appwindow.window.scale_factor())); - let _ = tx.send(position); + WebviewMessage::WithWebview(_) => { + unreachable!("window-dependent message routed to window-independent handler") } - WebviewMessage::Size(tx) => { - let bounds = child.bounds().ok_or(Error::FailedToSendMessage); - 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::Print => child.host.print(), WebviewMessage::AddEventListener(event_id, handler) => { child.listeners.lock().unwrap().insert(event_id, handler); @@ -677,16 +1131,6 @@ impl WinitCefApp { }; child.host.set_zoom_level(zoom_level); } - WebviewMessage::SetAutoResize(auto_resize) => { - if auto_resize { - let bounds = child.bounds(); - let parent_size = appwindow.window.surface_size(); - let scale = appwindow.window.scale_factor(); - child.bounds_rate = compute_child_bounds_rate(bounds.as_ref(), true, parent_size, scale); - } else { - child.bounds_rate = None; - } - } WebviewMessage::SetBackgroundColor(color) => child.set_background_color(color), WebviewMessage::ClearAllBrowsingData => { if let Some(manager) = child.cookie_manager() { @@ -723,55 +1167,22 @@ impl WinitCefApp { cookie::delete_cookie(manager, url, cookie); } } - WebviewMessage::Reparent(target_window_id, tx) => { - if window_id == target_window_id { - let _ = tx.send(Ok(())); - return; - } - - if !self.state.windows.contains_key(&target_window_id) { - let _ = tx.send(Err(Error::WindowNotFound)); - return; + // Refused here rather than by Chromium: the preference that would have told + // Chromium to refuse it also switches off the DevTools protocol this runtime + // starts every webview with. See `DevToolsPolicy`. + #[cfg(any(debug_assertions, feature = "devtools"))] + WebviewMessage::OpenDevTools => { + if child.devtools_enabled { + child.host.show_dev_tools(None, None, None, None); + } else { + log::warn!( + "not opening devtools for webview {:?}: they are disabled for this webview \ + or by Cef::devtools", + child.label + ); } - - let Some(mut child) = self - .state - .windows - .get_mut(&window_id) - .and_then(|appwindow| { - appwindow - .children - .iter() - .position(|child| child.webview_id == webview_id) - .map(|index| appwindow.children.remove(index)) - }) - else { - let _ = tx.send(Err(Error::WindowNotFound)); - return; - }; - - let Some(target_appwindow) = self.state.windows.get_mut(&target_window_id) else { - let _ = tx.send(Err(Error::WindowNotFound)); - return; - }; - - let bounds = child.bounds().unwrap_or_else(|| Rect { - position: PhysicalPosition::new(0, 0).into(), - size: target_appwindow.window.surface_size().into(), - }); - child.reparent(target_appwindow); - child.set_bounds( - target_appwindow.window.surface_size(), - target_appwindow.window.scale_factor(), - bounds, - ); - - target_appwindow.children.push(child); - let _ = tx.send(Ok(())); } #[cfg(any(debug_assertions, feature = "devtools"))] - WebviewMessage::OpenDevTools => child.host.show_dev_tools(None, None, None, None), - #[cfg(any(debug_assertions, feature = "devtools"))] WebviewMessage::CloseDevTools => child.host.close_dev_tools(), #[cfg(any(debug_assertions, feature = "devtools"))] WebviewMessage::IsDevToolsOpen(tx) => _ = tx.send(child.host.has_dev_tools() == 1), @@ -800,6 +1211,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(())); @@ -810,10 +1222,154 @@ impl WinitCefApp { let _ = tx.send(Ok(())); } } + WebviewMessage::SetBounds(_) + | WebviewMessage::SetSize(_) + | WebviewMessage::SetPosition(_) + | WebviewMessage::Position(_) + | WebviewMessage::Size(_) + | WebviewMessage::SetAutoResize(_) + | WebviewMessage::Reparent(_, _) => { + unreachable!("window-dependent message routed to window-independent handler") + } } } } +#[derive(Clone, Copy, Debug)] +pub enum RuntimeStyle { + Alloy, + Chrome, +} + +/// The CEF-specific webview attributes, set through +/// [`WebviewWindowBuilderCefExt`](crate::WebviewWindowBuilderCefExt). +/// +/// # Permission requests on CEF +/// +/// The runtime honors `WebviewAttributes::on_permission_request` — an `Allow` +/// grants without showing Chrome's prompt, a `Deny` refuses without one — with +/// three things worth knowing before relying on it. +/// +/// ## Most decisions are made once per origin and then persist +/// +/// Chromium consults a permission prompt only while the stored content setting +/// for that (origin, permission) still says "ask", and answering the prompt +/// persists the decision to the on-disk profile. Everything routed through the +/// prompt therefore reaches the handler **once per origin and permission, ever**, +/// including across restarts, and nothing calls back to say the app changed its +/// mind: revoking a grant means rewriting the content setting through the request +/// context. +/// +/// Camera and microphone are the exception. Chromium routes *every* +/// `getUserMedia()` call through the media path, so those two do reach the handler +/// on each call and a changing answer is honored. +/// +/// ## Permissions Tauri has no kind for arrive as `PermissionKind::Other` +/// +/// Chromium has more request types than Tauri has kinds. Storage access, FedCM, +/// protocol handler registration, idle detection, local and loopback network +/// access, web app installation, the WebXR sessions, hand tracking, keyboard lock +/// and disk quota all arrive as `PermissionKind::Other`, as does any request type +/// a future CEF build adds. +/// +/// Failing closed is deliberate, but it means a handler written elsewhere as +/// `match kind { Camera => Allow, _ => Deny }` hard-denies all of them here, and +/// denying storage access or FedCM breaks third-party SSO flows outright. Return +/// `PermissionResponse::Default` for the kinds you did not mean to answer about, +/// and CEF's own handling runs for them unchanged. +/// +/// ## `PermissionKind::DisplayCapture` is never granted by an `Allow` +/// +/// A `getDisplayMedia()` request the handler answers `Allow` is handed back to +/// CEF, which shows Chromium's desktop media picker under Chrome style and refuses +/// under Alloy style. A `Deny` still refuses it outright. +/// +/// Granting it from the handler would grant *everything*: CEF builds the stream +/// from the permission mask, and a desktop video bit with no requested source +/// synthesises the full desktop and returns it with no picker at all. Since +/// `PermissionKind::DisplayCapture` names no screen, window or tab, a blanket +/// `.on_permission_request(|_| PermissionResponse::Allow)` would silently hand any +/// page in the webview a full-desktop stream. +/// +/// # Chrome accelerators an app window does not get +/// +/// A Chrome style browser keeps its whole accelerator table live even hosted as a +/// child view with no browser UI, so this runtime swallows the commands that have +/// no meaning in an app window (new window and tab, the tab strip, history and +/// downloads and settings, print, save page, view source, the omnibox focus +/// commands). Any family of them can be kept with +/// [`allow_chrome_commands`](crate::WebviewWindowBuilderCefExt::allow_chrome_commands); +/// see [`ChromeCommandGroup`] for what each family covers. Two of the exclusions +/// take away keystrokes users expect: +/// +/// - **Zoom.** `WebviewAttributes::zoom_hotkeys_enabled` is honored, and it +/// **defaults to `false`**, so Ctrl+Plus, Ctrl+Minus and Ctrl+0 do not zoom +/// unless the webview opted in. Ctrl+mouse wheel zoom is unaffected either way, +/// since Chromium applies it in the render widget rather than through the +/// command controller. On Linux and macOS Tauri also injects a JavaScript zoom +/// polyfill when the flag is true, which coexists with Chrome's own accelerator, +/// so a keyboard zoom steps twice there. `WebviewDispatch::set_zoom` is +/// untouched. +/// +/// - **History.** Alt+Left and Alt+Right do not navigate the session history. +/// The browser is created at an internal placeholder URL and then navigated to +/// the app's own, so the app's first screen already sits on a second history +/// entry and going back from it lands on a blank page. The page context menu +/// drops Back and Forward for the same reason. `WebviewDispatch::go_back` and +/// `go_forward` are untouched, and an app that navigates its webview normally +/// can take the accelerators back with [`ChromeCommandGroup::History`]. +#[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>, + /// Observer of the messages the renderer writes to the JavaScript console. + /// + /// Scoped to this webview's own native browser, so neither a CEF-owned popup's + /// output nor that of a DevTools window opened on this webview is reported here. + pub console_message_handler: Option>, + /// Families of Chrome commands this webview keeps rather than swallows. + /// + /// Empty by default, which blocks every group in [`ChromeCommandGroup`]. + pub allowed_chrome_commands: Vec, + /// Last look at the [`cef::BrowserSettings`] before the browser is created. + /// + /// Runs after the runtime has mapped the portable [`WebviewAttributes`], so it can + /// change what the runtime decided as well as set the fields that have no portable + /// equivalent — the font families and sizes, `remote_fonts`, `local_storage`, + /// `databases`, `webgl`, `tab_to_links`, `javascript_dom_paste`, `default_encoding`. + pub browser_settings_callback: 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()) + .field( + "console_message_handler", + &self.console_message_handler.is_some(), + ) + .field("allowed_chrome_commands", &self.allowed_chrome_commands) + .field( + "browser_settings_callback", + &self.browser_settings_callback.is_some(), + ) + .finish() + } +} + #[derive(Debug, Clone)] pub struct CefInitScript { pub(crate) script: String, @@ -822,6 +1378,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())); @@ -840,6 +1401,18 @@ impl CefInitScript { } } +/// Whether the browser created for a webview will be Alloy style. +/// +/// CEF's default is Chrome style, with one exception this runtime always meets: on +/// macOS a browser given a native parent view - which is how every webview here is +/// hosted - is forced to Alloy style whatever the application asked for, because Chrome +/// style does not support a native parent there (`MaybeSetWindowInfo`, upstream issue +/// #3294). +#[cfg(any(debug_assertions, feature = "devtools"))] +fn is_alloy_style(runtime_style: Option) -> bool { + cfg!(target_os = "macos") || matches!(runtime_style, Some(RuntimeStyle::Alloy)) +} + pub(crate) fn initialization_scripts(attrs: &mut WebviewAttributes) -> Arc> { let mut initialization_scripts = Vec::new(); @@ -865,6 +1438,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 +1457,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 +1526,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 +1542,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 +1610,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 +1694,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(), @@ -1228,7 +1885,7 @@ impl WebviewDispatch for CefWebviewDispatcher { /// from the current window size; children with fixed bounds keep whatever bounds /// they were last given. pub(crate) fn layout_app_window(appwindow: &AppWindow) { - let parent_size = appwindow.window.surface_size(); + let parent_size = appwindow.safe_surface_size(); let win_w = parent_size.width as f32; let win_h = parent_size.height as f32; let scale = appwindow.window.scale_factor(); @@ -1296,17 +1953,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 +2019,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 +2063,7 @@ type EvalScriptCallback = Box; cef::wrap_dev_tools_message_observer! { struct EvalScriptWithCallbackDevToolsObserver { - message_id: Arc, + message_id: i32, callback: Arc>>, registration: Arc>>, } @@ -1411,7 +2076,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; } @@ -1434,14 +2099,51 @@ cef::wrap_dev_tools_message_observer! { /// Registers a DevTools protocol observer. Returns the [`cef::Registration`] which must be /// kept alive for the observer to stay registered. The observer is unregistered when /// the Registration is dropped. +/// Overrides the user agent of one native browser through the DevTools protocol. +/// +/// `CefSettings.user_agent` is process-wide and fixed before any browser exists, so it +/// cannot answer `WebviewAttributes::user_agent`. `Emulation.setUserAgentOverride` can: +/// it is scoped to this target and applies to both the `User-Agent` request header and +/// `navigator.userAgent`, for the life of the browser. +/// +/// Sent before the initial navigation so the first request already carries it. Client +/// hints are deliberately left alone: overriding the user agent without them is what +/// Chromium itself does for the `--user-agent` switch. +/// +/// Scoped to this one native browser, so a CEF-owned popup keeps the process-wide user +/// agent from `Cef::user_agent`; use that one to cover popups too. +fn apply_user_agent_override(host: &BrowserHost, user_agent: &str) { + let Ok(message_id) = crate::devtools::allocate_runtime_devtools_message_id() else { + log::warn!("could not set the webview user agent: no DevTools message id was available"); + return; + }; + + let message = serde_json::json!({ + "id": message_id, + "method": "Emulation.setUserAgentOverride", + "params": { "userAgent": user_agent }, + }) + .to_string(); + + if host.send_dev_tools_message(Some(message.as_bytes())) != 1 { + log::warn!("failed to set the webview user agent through the DevTools protocol"); + } +} + 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 +2196,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 +2223,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 +2275,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..e8e3439 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,18 @@ 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"))] +#[cfg(any( + windows, + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] 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; @@ -46,7 +51,10 @@ use winit::platform::windows::WindowExtWindows; use crate::window_handle::SoftbufferWindowHandle; use crate::{ cef_impl::{client as browser_client, request_context}, - runtime::{AfterWindowCreationCallback, CefRuntime, Message, RuntimeContext, WinitCefApp}, + runtime::{ + AfterWindowCreationCallback, CefRuntime, Message, RuntimeContext, WinitCefApp, + WinitDragDropState, + }, webview::{AppWebview, CefWebviewDispatcher, create_webview_detached}, window_builder::WindowBuilderWrapper, window_handle::SendRawWindowHandle, @@ -55,6 +63,42 @@ use crate::{ type WindowEventListener = Box; type WindowEventListeners = Arc>>; +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +pub(crate) struct SendGtkWindow(*mut std::ffi::c_void); + +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +unsafe impl Send for SendGtkWindow {} + +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +pub(crate) struct SendGtkBox(*mut std::ffi::c_void); + +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +unsafe impl Send for SendGtkBox {} + pub(crate) fn tauri_theme_to_winit_theme(theme: Option) -> Option { theme.map(|theme| match theme { Theme::Light => winit::window::Theme::Light, @@ -214,9 +258,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 +278,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, @@ -263,6 +334,22 @@ pub(crate) enum WindowMessage { PrimaryMonitor(Sender>>), MonitorFromPoint(Sender>>, f64, f64), AvailableMonitors(Sender>>), + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + GtkWindow(Sender>), + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + DefaultVBox(Sender>), RawWindowHandle(Sender>), Theme(Sender>), Center, @@ -319,7 +406,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 +464,21 @@ 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>, + pub(crate) native_drag_drop: Option, + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + /// X11 parent for CEF browser children, sized to the GTK content area so + /// GTK UI like menus stays outside the native CEF child-window stack. + pub(crate) cef_host: crate::platform::linux::CefX11Host, + /// 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)] @@ -360,6 +506,16 @@ pub(crate) struct AppWindowAttrs { target_os = "openbsd" ))] pub(crate) skip_taskbar: bool, + /// Parent this window is transient for, owning the reference transferred by + /// [`WindowBuilder::transient_for`](tauri_runtime::window::WindowBuilder::transient_for). + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + pub(crate) transient_for: Option, } impl AppWindow { @@ -387,6 +543,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 @@ -399,10 +579,37 @@ impl AppWindow { self.preferred_theme().or(app_wide_theme) } + /// Size available for CEF child layout. On Linux this is the GTK content-area + /// X11 host size, excluding GTK UI such as menus; elsewhere it is the window + /// surface size. + pub(crate) fn safe_surface_size(&self) -> PhysicalSize { + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + { + self.cef_host.size() + } + + #[cfg(not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )))] + self.window.surface_size() + } + pub(crate) fn set_theme(&mut self, theme: Option) { 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) { @@ -433,8 +640,21 @@ impl WinitCefApp { .create_window(attrs.inner.clone()) .map_err(|_| Error::CreateWindow)?; + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + let cef_host = + crate::platform::linux::CefX11Host::new(window.as_ref()).ok_or(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 +663,20 @@ impl WinitCefApp { attrs, children: Vec::new(), listeners: Default::default(), - reported_focus: false, - #[cfg(target_os = "macos")] - appkit_state: Arc::new(RwLock::new(AppkitState::default())), + native_drag_drop: None, + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + cef_host, + 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); @@ -467,18 +693,43 @@ impl WinitCefApp { { appwindow.set_visible_on_all_workspaces(appwindow.attrs.visible_on_all_workspaces); appwindow.set_skip_taskbar(appwindow.attrs.skip_taskbar); + appwindow.apply_transient_for(); } #[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() { appwindow.set_background_color(appwindow.attrs.background_color); } + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + if let Some(after_window_creation) = _after_window_creation { + use gtk::glib::translate::ToGlibPtr; + use winit::platform::gtk4::WindowExtGtk4; + + let gtk_window = appwindow.window.gtk_window().unwrap(); + let default_vbox = appwindow.cef_host.default_vbox(); + after_window_creation(RawWindow { + gtk_window: { + let ptr: *mut gtk::ffi::GtkApplicationWindow = gtk_window.to_glib_none().0; + ptr as *mut std::ffi::c_void + }, + default_vbox: Some({ + let ptr: *mut gtk::ffi::GtkBox = default_vbox.to_glib_none().0; + ptr as *mut std::ffi::c_void + }), + _marker: &PhantomData, + }); + } + #[cfg(any(windows, target_os = "macos"))] if let Some(after_window_creation) = _after_window_creation { after_window_creation(RawWindow { @@ -512,6 +763,63 @@ 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; + } + } + + /// Re-lays out the CEF children of every window whose X11 host was resized by GTK without the + /// toplevel changing size. + /// + /// GTK owns the content area, so attaching, hiding or showing a menu bar moves and resizes the + /// host while winit reports no `SurfaceResized` for the toplevel. Without this the children + /// would keep the bounds computed against the previous host size until the user resizes the + /// window. + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + pub(crate) fn apply_pending_host_layouts(&mut self) { + for appwindow in self.state.windows.values() { + if appwindow.cef_host.take_needs_relayout() { + crate::webview::layout_app_window(appwindow); + } + } + } + pub(crate) fn handle_window_message( &mut self, event_loop: &dyn ActiveEventLoop, @@ -540,9 +848,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())), @@ -616,6 +922,35 @@ impl WinitCefApp { .collect(); let _ = tx.send(Ok(monitors)); } + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + WindowMessage::GtkWindow(tx) => { + use gtk::glib::translate::ToGlibPtr; + use winit::platform::gtk4::WindowExtGtk4; + + let gtk_window = appwindow.window.gtk_window().unwrap(); + let ptr: *mut gtk::ffi::GtkApplicationWindow = gtk_window.to_glib_full(); + let _ = tx.send(Ok(SendGtkWindow(ptr as *mut std::ffi::c_void))); + } + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + WindowMessage::DefaultVBox(tx) => { + use gtk::glib::translate::ToGlibPtr; + + let default_vbox = appwindow.cef_host.default_vbox(); + let ptr: *mut gtk::ffi::GtkBox = default_vbox.to_glib_full(); + let _ = tx.send(Ok(SendGtkBox(ptr as *mut std::ffi::c_void))); + } WindowMessage::RawWindowHandle(tx) => { let handle = window.window_handle(); let send_handle = handle @@ -660,7 +995,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 +1088,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 +1115,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); } @@ -948,8 +1283,8 @@ impl WindowDispatch for CefWindowDispatcher { target_os = "netbsd", target_os = "openbsd" ))] - fn gtk_window(&self) -> Result { - Err(Error::FailedToSendMessage) + fn gtk_window(&self) -> Result<*mut std::ffi::c_void> { + window_getter!(self, GtkWindow).map(|gtk_window| gtk_window.0) } #[cfg(any( @@ -959,8 +1294,8 @@ impl WindowDispatch for CefWindowDispatcher { target_os = "netbsd", target_os = "openbsd" ))] - fn default_vbox(&self) -> Result { - Err(Error::FailedToSendMessage) + fn default_vbox(&self) -> Result<*mut std::ffi::c_void> { + window_getter!(self, DefaultVBox).map(|gtk_box| gtk_box.0) } fn window_handle( @@ -1211,7 +1546,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 +1623,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 +1674,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 +1710,7 @@ where }, }, use_https_scheme, + devtools, }); Ok(DetachedWindow { diff --git a/src/window_builder.rs b/src/window_builder.rs index 9164cf0..dcf0a36 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 } @@ -423,7 +450,13 @@ impl WindowBuilder for WindowBuilderWrapper { target_os = "netbsd", target_os = "openbsd" ))] - fn transient_for(self, _parent: &impl gtk::glib::IsA) -> Self { + fn transient_for(mut self, parent: *mut std::ffi::c_void) -> Self { + use gtk::glib::translate::FromGlibPtrFull; + + // SAFETY: `transient_for` receives the parent as transfer full, so the wrapper adopts the + // reference and releases it when the builder (or the window it creates) is dropped. + self.attrs.transient_for = + Some(unsafe { gtk::Window::from_glib_full(parent as *mut gtk::ffi::GtkWindow) }); self } @@ -498,6 +531,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() {}