//! Tauri glue that does not depend on the app's own state: caller checks, shell events, tab //! webview lookups and actions, app-data JSON files, profile data folders and the Windows system //! menu. The app keeps its `#[tauri::command]` wrappers (so IPC names and capabilities stay the //! app's) and calls these. use crate::{ policy, profiles::{self, ProfileSettings}, site::SiteSpec, tabs::{self, TabAction, TabState}, }; use serde::Serialize; use tauri::{AppHandle, Emitter, EventTarget, Manager, Runtime, Webview}; use url::Url; /// Shell-only commands: the trusted local `shell` webview (debug builds also accept the dev server). pub fn shell_only(webview: &Webview) -> Result<(), String> { let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify workspace origin")?; policy::require_local(webview.label(), &url, cfg!(debug_assertions)) } /// Tab-chrome commands: any tab webview, speaking only about itself. Returns its tab id. pub fn tab_only(webview: &Webview) -> Result { policy::tab_id(webview.label()) .map(str::to_owned) .ok_or_else(|| "FORBIDDEN: tab webview required".into()) } /// Site-only commands: a tab webview that is exactly on the site right now. Returns its URL (the /// webview's own, never the page's claim). pub fn site_tab_only(site: &SiteSpec, webview: &Webview) -> Result { let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?; policy::require_site_tab(site, webview.label(), &url)?; Ok(url) } pub fn emit_shell(app: &AppHandle, event: &str, payload: S) { let _ = app.emit_to(EventTarget::webview(policy::SHELL_LABEL), event, payload); } pub fn emit_tab_state(app: &AppHandle, state: TabState) { emit_shell(app, tabs::EVENT_TAB_STATE, state); } pub fn tab_webview(app: &AppHandle, id: &str) -> Option> { app.get_webview(&policy::tab_label(id)) } /// Evaluates `script` in every tab that is on the site right now. pub fn eval_in_site_tabs(app: &AppHandle, site: &SiteSpec, script: &str) { // Safe mode: site pages run none of the app's layers, so nothing is pushed into them either. if crate::safe::active() { return; } for (label, webview) in app.webviews() { if policy::tab_id(&label).is_some() && webview.url().is_ok_and(|url| site.is_origin(&url)) { let _ = webview.eval(script); } } } pub fn data_file(app: &AppHandle, name: &str) -> Option { app.path().app_data_dir().ok().map(|dir| dir.join(name)) } /// `read_json::(app, name)`: the app-data JSON file `name`, or None when missing or invalid. pub fn read_json(app: &AppHandle, name: &str) -> Option { serde_json::from_str(&std::fs::read_to_string(data_file(app, name)?).ok()?).ok() } pub fn write_json(app: &AppHandle, name: &str, value: &T) -> Result<(), String> { let path = data_file(app, name).ok_or("STORAGE: no application data directory")?; let json = serde_json::to_string_pretty(value).map_err(|_| "STORAGE: serialization failed")?; std::fs::write(path, json).map_err(|error| format!("STORAGE: {error}")) } pub fn navigate_tab(app: &AppHandle, id: &str, url: &str) -> Result<(), String> { let url = tabs::parse_web_url(url)?; let tab = tab_webview(app, id).ok_or("TAB: not found")?; tab.navigate(url).map_err(|error| format!("TAB: {error}")) } pub fn tab_action(app: &AppHandle, id: &str, action: &str) -> Result<(), String> { let action = TabAction::parse(action)?; let tab = tab_webview(app, id).ok_or("TAB: not found")?; let result = match action { TabAction::Back => tab.eval("history.back()"), TabAction::Forward => tab.eval("history.forward()"), TabAction::Reload => tab.reload(), TabAction::Stop => tab.eval("window.stop()"), TabAction::Focus => tab.set_focus(), }; result.map_err(|error| format!("TAB: {error}")) } /// Whether Shift is held right now (asked once while the app starts: safe mode). Windows only; /// elsewhere the launch argument is the way in. pub fn shift_held() -> bool { #[cfg(windows)] { use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_SHIFT}; // SAFETY: a plain Win32 key-state read. The high bit means the key is down. unsafe { GetAsyncKeyState(i32::from(VK_SHIFT.0)) < 0 } } #[cfg(not(windows))] { false } } /// The remembered page zoom per tab ([`crate::zoom`], app data `zoom.json`), read once. fn zooms(app: &AppHandle) -> std::sync::MutexGuard<'static, crate::zoom::ZoomMap> { use std::sync::{Mutex, OnceLock}; static ZOOMS: OnceLock> = OnceLock::new(); ZOOMS .get_or_init(|| Mutex::new(read_json::(app, crate::zoom::ZOOM_FILE).unwrap_or_default().validated())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } /// `tab_zoom`: sets tab `id`'s page zoom to `factor` (clamped to 25%..500%, the engine's own page /// zoom) and remembers it for that tab; without a factor only reads it. Returns the factor in effect. pub fn tab_zoom(app: &AppHandle, id: &str, factor: Option) -> Result { if !policy::valid_tab_id(id) { return Err("VALIDATION: invalid tab id".into()); } let Some(factor) = factor else { return Ok(zooms(app).get(id)) }; let tab = tab_webview(app, id).ok_or("TAB: not found")?; let (before, factor, saved) = { let mut map = zooms(app); let before = map.get(id); let factor = map.set(id, factor)?; (before, factor, map.clone()) }; if let Err(error) = tab.set_zoom(factor) { // Not applied: what is remembered stays what the tab shows. let _ = zooms(app).set(id, before); return Err(format!("TAB: {error}")); } if factor != before { write_json(app, crate::zoom::ZOOM_FILE, &saved)?; } Ok(factor) } /// A tab webview that was just created, or loads a page: it shows its remembered zoom (a new /// webview starts at 100%). Tabs at 100% are left alone. pub fn restore_tab_zoom(app: &AppHandle, tab: &Webview) { let Some(id) = policy::tab_id(tab.label()) else { return }; let factor = zooms(app).get(id); if factor != crate::zoom::DEFAULT { let _ = tab.set_zoom(factor); } } /// A closed tab's zoom is forgotten (a tab that comes back under the same id starts at 100%). pub fn forget_tab_zoom(app: &AppHandle, id: &str) { let saved = { let mut map = zooms(app); map.forget(id).then(|| map.clone()) }; if let Some(map) = saved { let _ = write_json(app, crate::zoom::ZOOM_FILE, &map); } } /// `tab_find`: searches tab `id`'s page ([`crate::find`]). The engine's own find where this build /// can drive it (WebView2's `ICoreWebView2Find`, which reports counts through `tab-find` events); /// otherwise the evaluated Custom Highlight fallback ([`crate::find::fallback_script`]). The query is kept nowhere. pub fn tab_find(app: &AppHandle, id: &str, action: &str, query: Option<&str>) -> Result<(), String> { if !policy::valid_tab_id(id) { return Err("VALIDATION: invalid tab id".into()); } let request = crate::find::request(action, query)?; let tab = tab_webview(app, id).ok_or("TAB: not found")?; #[cfg(windows)] { let fallback = tab.clone(); let (app, id) = (app.clone(), id.to_owned()); tab.with_webview(move |platform| { // The engine has no find interface (an older WebView2 Runtime): the evaluated fallback. if !find_windows::run(&app, &platform, &id, &request) { let _ = fallback.eval(crate::find::fallback_script(&request)); } }) .map_err(|error| format!("TAB: {error}")) } #[cfg(not(windows))] { tab.eval(crate::find::fallback_script(&request)).map_err(|error| format!("TAB: {error}")) } } /// WebView2's own find (`ICoreWebView2Find`, WebView2 Runtime with `ICoreWebView2_28`): the find /// session, its highlights and its counts are the engine's; its own find bar stays hidden. #[cfg(windows)] mod find_windows { use super::{emit_shell, AppHandle, Runtime}; use crate::find::{FindAction, FindRequest, FindState, EVENT_TAB_FIND}; use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; use webview2_com::Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment15, ICoreWebView2Find, ICoreWebView2_28}; use webview2_com::{FindActiveMatchIndexChangedEventHandler, FindMatchCountChangedEventHandler, FindStartCompletedHandler}; use windows::core::{Interface, HSTRING}; /// Tab webviews whose find object already reports its counts (one pair of handlers each). fn watched() -> std::sync::MutexGuard<'static, HashSet> { static WATCHED: OnceLock>> = OnceLock::new(); WATCHED.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } /// The tab's webview is gone: a new one under the same id registers its own handlers. pub(super) fn forget(id: &str) { watched().remove(id); } fn report(app: &AppHandle, id: &str, find: &ICoreWebView2Find) { let (mut count, mut index) = (0i32, -1i32); // SAFETY: COM property reads on the live find object, on the main thread. unsafe { let _ = find.MatchCount(&mut count); let _ = find.ActiveMatchIndex(&mut index); } emit_shell(app, EVENT_TAB_FIND, FindState::from_webview2(id, count, index)); } /// Runs `request` with the engine's find on the main thread; false when this runtime has none. pub(super) fn run(app: &AppHandle, platform: &tauri::webview::PlatformWebview, id: &str, request: &FindRequest) -> bool { // SAFETY: COM calls on this webview's live controller and environment, on the main thread // (with_webview runs the closure there; the handlers run there too). unsafe { let Ok(core) = platform.controller().CoreWebView2() else { return false }; let (Ok(core), Ok(environment)) = (core.cast::(), platform.environment().cast::()) else { return false }; let Ok(find) = core.Find() else { return false }; if watched().insert(id.to_owned()) { let mut token = 0i64; let (count_app, count_id) = (app.clone(), id.to_owned()); let counted = FindMatchCountChangedEventHandler::create(Box::new(move |sender, _| { if let Some(find) = sender { report(&count_app, &count_id, &find); } Ok(()) })); let _ = find.add_MatchCountChanged(&counted, &mut token); let (index_app, index_id) = (app.clone(), id.to_owned()); let moved = FindActiveMatchIndexChangedEventHandler::create(Box::new(move |sender, _| { if let Some(find) = sender { report(&index_app, &index_id, &find); } Ok(()) })); let _ = find.add_ActiveMatchIndexChanged(&moved, &mut token); } match (request.action, request.query.as_deref()) { (FindAction::Start, Some(query)) => { let Ok(options) = environment.CreateFindOptions() else { return false }; let _ = options.SetFindTerm(&HSTRING::from(query)); let _ = options.SetSuppressDefaultFindDialog(true); let _ = options.SetShouldHighlightAllMatches(true); let _ = options.SetIsCaseSensitive(false); let _ = options.SetShouldMatchWord(false); // A new query is a new session, from the first match. let _ = find.Stop(); let (done_app, done_id, done_find) = (app.clone(), id.to_owned(), find.clone()); let started = FindStartCompletedHandler::create(Box::new(move |_| { report(&done_app, &done_id, &done_find); Ok(()) })); let _ = find.Start(&options, &started); } (FindAction::Next, _) => { let _ = find.FindNext(); } (FindAction::Previous, _) => { let _ = find.FindPrevious(); } (FindAction::Stop, _) | (FindAction::Start, None) => { let _ = find.Stop(); } } } true } } /// A browser shortcut pressed inside tab `id`, forwarded to the shell (`tab-shortcut`). pub fn forward_shortcut(app: &AppHandle, id: String, shortcut: String) -> Result<(), String> { if !tabs::valid_shortcut(&shortcut) { return Err("VALIDATION: unknown shortcut".into()); } #[derive(Clone, Serialize)] struct Shortcut { id: String, shortcut: String, } emit_shell(app, tabs::EVENT_TAB_SHORTCUT, Shortcut { id, shortcut }); Ok(()) } /// A link drag started/entered/ended in tab `id` (`link-drag`); the link opens only as a new tab. pub fn forward_link_drag(app: &AppHandle, id: String, phase: String, url: Option) -> Result<(), String> { let url = tabs::link_drag(&phase, url)?; #[derive(Clone, Serialize)] struct LinkDrag { id: String, phase: String, url: Option, } emit_shell(app, tabs::EVENT_LINK_DRAG, LinkDrag { id, phase, url }); Ok(()) } /// Deletes the WebView data folders (the logins) of removed profiles. pub fn remove_profile_dirs(app: &AppHandle, site: &SiteSpec, removed: &[String]) { let Ok(local) = app.path().app_local_data_dir() else { return }; for id in removed { if let Some(dir) = profiles::data_dir(&local, id) { if let Err(error) = std::fs::remove_dir_all(&dir) { if error.kind() != std::io::ErrorKind::NotFound { eprintln!("{}: could not remove profile data {}: {error}", site.product, dir.display()); } } } } } /// Deletes WebView data folders of profiles that no longer exist (see [`profiles::orphan_dirs`]). pub fn prune_profile_dirs(app: &AppHandle, settings: &ProfileSettings) { let Ok(local) = app.path().app_local_data_dir() else { return }; for dir in profiles::orphan_dirs(&local, settings) { let _ = std::fs::remove_dir_all(dir); } } /// Moves app-data and app-local data (WebView profile, i.e. the site login) from a previous bundle /// identifier. Never overwrites an existing new folder. pub fn migrate_identifier(app: &AppHandle, site: &SiteSpec, legacy_identifier: &str) { let dirs = [app.path().app_data_dir(), app.path().app_local_data_dir()]; for new_dir in dirs.into_iter().flatten() { let Some(parent) = new_dir.parent() else { continue }; let old_dir = parent.join(legacy_identifier); if old_dir.is_dir() && !new_dir.exists() { if let Err(error) = std::fs::rename(&old_dir, &new_dir) { eprintln!("{}: could not migrate {}: {error}", site.product, old_dir.display()); } } } } /// Shows the native window system menu (Restore, Move, Size, Minimize, Maximize, Close) at a /// screen position, for a right-click on the custom titlebar. No-op outside Windows. pub fn system_menu(webview: &Webview, x: f64, y: f64) -> Result<(), String> { if !x.is_finite() || !y.is_finite() { return Err("VALIDATION: non-finite position".into()); } #[cfg(windows)] { use windows::Win32::Foundation::{LPARAM, WPARAM}; use windows::Win32::UI::WindowsAndMessaging::{ GetSystemMenu, PostMessageW, SetForegroundWindow, TrackPopupMenu, TPM_RETURNCMD, TPM_RIGHTBUTTON, WM_SYSCOMMAND, }; let hwnd = webview.window().hwnd().map_err(|error| format!("WINDOW: {error}"))?; // SAFETY: plain Win32 calls on our own top-level window handle. unsafe { let menu = GetSystemMenu(hwnd, false); if menu.is_invalid() { return Ok(()); } let _ = SetForegroundWindow(hwnd); let command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, x as i32, y as i32, None, hwnd, None); if command.0 != 0 { let _ = PostMessageW(Some(hwnd), WM_SYSCOMMAND, WPARAM(command.0 as usize), LPARAM(0)); } } } #[cfg(not(windows))] let _ = webview; Ok(()) } /// Windows: a graceful close sent to one of the process's helper windows closes the app window /// instead. Call once from `setup`, on the main thread, after the main window is built. No-op /// elsewhere. /// /// tao's thread event target and the single-instance plugin's message window are visible (1 px, /// layered, transparent) unowned top-level windows. `taskkill /PID` without `/F` posts one /// `WM_CLOSE` to the first such window in z-order; a minimized window sinks to the bottom, so the /// close hit a helper, `DefWindowProc` destroyed it (a second instance could no longer find us, /// or the event loop lost its target) and the app kept running. pub fn route_helper_closes(window: &tauri::Window) { #[cfg(windows)] helper_close::install(window); #[cfg(not(windows))] let _ = window; } #[cfg(windows)] mod helper_close { use windows::core::BOOL; use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM}; use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}; use windows::Win32::UI::WindowsAndMessaging::{ EnumThreadWindows, GetWindow, IsWindow, IsWindowVisible, PostMessageW, GW_OWNER, WM_CLOSE, WM_NCDESTROY, }; const SUBCLASS_ID: usize = 0x7762_636c; // "wbcl" pub(super) fn install(window: &tauri::Window) { let Ok(main) = window.hwnd() else { return }; // SAFETY: enumerates and subclasses windows of the calling (event loop) thread only; // `main` is passed by value through the LPARAM / ref data. unsafe { let _ = EnumThreadWindows(GetCurrentThreadId(), Some(visit), LPARAM(main.0 as isize)); } } unsafe extern "system" fn visit(hwnd: HWND, main: LPARAM) -> BOOL { let main = HWND(main.0 as *mut _); // Visible, unowned, not the app window: what `taskkill` may pick. IME windows are hidden. if hwnd != main && IsWindowVisible(hwnd).as_bool() && GetWindow(hwnd, GW_OWNER).is_err() { let _ = SetWindowSubclass(hwnd, Some(forward), SUBCLASS_ID, main.0 as usize); } BOOL(1) } unsafe extern "system" fn forward( hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM, id: usize, main: usize, ) -> LRESULT { match msg { WM_CLOSE => { let main = HWND(main as *mut _); if IsWindow(Some(main)).as_bool() { let _ = PostMessageW(Some(main), WM_CLOSE, WPARAM(0), LPARAM(0)); return LRESULT(0); } } WM_NCDESTROY => { let _ = RemoveWindowSubclass(hwnd, Some(forward), id); } _ => {} } DefSubclassProc(hwnd, msg, wparam, lparam) } } /// Places a child webview (tab, popover) at a rectangle of its window, in logical px. /// /// Windows/macOS: the native child bounds. Linux: tauri 2 packs every child webview into the /// window's GtkBox (`build_gtk` with a box: stacked one under another, bounds ignored), so the host /// moves each one into the GtkOverlay made by [`attach_shell`] and places it there. pub fn place_child(webview: &Webview, x: f64, y: f64, width: f64, height: f64) { #[cfg(target_os = "linux")] linux::place(webview, x, y, width, height); // One native move+resize: separate set_position/set_size calls were two WebView2 `SetBounds` // (the page re-laid out at the old size, then at the new one) and two main-thread messages. #[cfg(not(target_os = "linux"))] { let _ = webview.set_bounds(tauri::Rect { position: tauri::LogicalPosition::new(x, y).into(), size: tauri::LogicalSize::new(width, height).into(), }); #[cfg(target_os = "macos")] if crate::diag::active() { let actual = webview.bounds().map(|b| { let (p, s) = (b.position.to_logical::(1.0), b.size.to_logical::(1.0)); format!("{},{} {}x{}", p.x, p.y, s.width, s.height) }); crate::diag::log("host", &format!("place {} asked {x},{y} {width}x{height} now {actual:?}", webview.label())); } } } /// Linux: the shell webview becomes the base layer of a GtkOverlay filling the window, which /// [`place_child`] layers tab webviews over. Call once, right after the shell is added. No-op /// elsewhere. pub fn attach_shell(shell: &Webview) { #[cfg(target_os = "linux")] linux::attach_shell(shell); #[cfg(not(target_os = "linux"))] let _ = shell; } /// Puts the shell webview above every tab webview (`on_top`), so shell menus, dialogs and drag /// zones draw over the panes while the panes stay on screen (the shell page must then be /// transparent where the panes are), or back below them. Returns whether this platform can. /// Windows: WebView2 hosts each webview in its own child window, re-ordered with `SetWindowPos` /// (no activation or focus change). Linux: the shell's place among the GtkOverlay's layers /// ([`attach_shell`]). macOS: the shell's WKWebView re-ordered among its superview's subviews /// (first responder kept). Elsewhere the caller hides the tabs instead. pub fn raise_shell(shell: &Webview, on_top: bool) -> bool { #[cfg(windows)] { let _ = shell.with_webview(move |platform| { use windows::Win32::Foundation::HWND; use windows::Win32::UI::WindowsAndMessaging::{ SetWindowPos, HWND_BOTTOM, HWND_TOP, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, }; let mut container = HWND::default(); // SAFETY: the shell's own live controller and its container window, on the main // thread (with_webview runs the closure there). unsafe { if platform.controller().ParentWindow(&mut container).is_ok() && !container.is_invalid() { let after = if on_top { HWND_TOP } else { HWND_BOTTOM }; let _ = SetWindowPos( container, Some(after), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER, ); } } }); true } #[cfg(target_os = "linux")] { linux::raise_shell(shell, on_top); true } #[cfg(target_os = "macos")] { macos::raise_shell(shell, on_top); true } #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] { let _ = (shell, on_top); false } } #[cfg(target_os = "macos")] mod macos { use objc2_app_kit::{NSResponder, NSView, NSWindowOrderingMode}; use tauri::{Runtime, Webview}; pub fn raise_shell(shell: &Webview, on_top: bool) { let _ = shell.with_webview(move |platform| { // SAFETY: `inner` is the shell's live WKWebView (an NSView) and its superview (the // window's content view, which owns it), on the main thread (with_webview runs the // closure there). let view: &NSView = unsafe { &*platform.inner().cast::() }; let Some(parent) = (unsafe { view.superview() }) else { return }; // Re-adding a subview can resign it as first responder: typing stays where it was. let window = view.window(); let focused = window.as_ref().and_then(|window| window.firstResponder()).is_some_and(|responder| { responder.downcast_ref::().is_some_and(|responder| responder.isDescendantOf(view)) }); let mode = if on_top { NSWindowOrderingMode::Above } else { NSWindowOrderingMode::Below }; parent.addSubview_positioned_relativeTo(view, mode, None); if focused { if let Some(window) = window { let responder: &NSResponder = view; window.makeFirstResponder(Some(responder)); } } }); } } /// Where [`place_child`] last put a child webview, in logical px (x, y, width, height). Linux /// only: there `Webview::position` is not the placement (always 0,0 outside X11 child windows). #[cfg(target_os = "linux")] pub fn placed_rect(webview: &Webview) -> Option<(f64, f64, f64, f64)> { linux::placed(webview.label()) } #[cfg(target_os = "linux")] mod linux { use gtk::prelude::*; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use tauri::{Runtime, Webview}; type Rect = (f64, f64, f64, f64); /// A child's placement in whole px (x, y, width, height), kept on the GTK widget. type GtkRect = (i32, i32, i32, i32); const RECT_KEY: &str = "waybetter-rect"; fn rects() -> std::sync::MutexGuard<'static, HashMap> { static RECTS: OnceLock>> = OnceLock::new(); RECTS.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } pub fn placed(label: &str) -> Option { rects().get(label).copied() } /// The shell is above the tab webviews ([`raise_shell`]). static RAISED: AtomicBool = AtomicBool::new(false); /// The shell is the overlay's first layer (under the tabs, which `place` adds above it) or, /// raised, its last. Tabs placed while it is raised go under it. pub fn raise_shell(shell: &Webview, on_top: bool) { RAISED.store(on_top, Ordering::SeqCst); let _ = shell.with_webview(move |platform| { let widget = platform.inner().upcast::(); let Some(overlay) = widget.parent().and_then(|parent| parent.downcast::().ok()) else { return }; overlay.reorder_overlay(&widget, if on_top { -1 } else { 0 }); }); } pub fn attach_shell(shell: &Webview) { let _ = shell.with_webview(|platform| { let widget = platform.inner().upcast::(); let Some(vbox) = widget.parent().and_then(|parent| parent.downcast::().ok()) else { return }; let overlay = gtk::Overlay::new(); // Exactly the placed rectangle: the default position uses the child's preferred size, // which WebKit (not the placement) decides. overlay.connect_get_child_position(|_, child| { // SAFETY: "waybetter-rect" is only ever set by `place`, always as `GtkRect`. let (x, y, w, h) = unsafe { *child.data::(RECT_KEY)?.as_ptr() }; Some(gtk::gdk::Rectangle::new(x, y, w, h)) }); // `widget` holds a reference, so removing it from the box does not destroy it. The // shell is an overlay layer too (an empty box is the base), so it can be re-ordered // above the tabs; with no placement it fills the overlay (Fill alignment). vbox.remove(&widget); widget.set_halign(gtk::Align::Fill); widget.set_valign(gtk::Align::Fill); overlay.add(>k::Box::new(gtk::Orientation::Vertical, 0)); overlay.add_overlay(&widget); vbox.pack_start(&overlay, true, true, 0); overlay.show(); }); } pub fn place(webview: &Webview, x: f64, y: f64, width: f64, height: f64) { rects().insert(webview.label().to_owned(), (x, y, width, height)); // Whole px, with the right/bottom edges rounded the same way as the left/top ones. let (left, top) = (x.round(), y.round()); let (w, h) = (((x + width).round() - left).max(1.0), ((y + height).round() - top).max(1.0)); let _ = webview.with_webview(move |platform| { let widget = platform.inner().upcast::(); // SAFETY: read back only as `GtkRect` (the overlay's get-child-position handler). unsafe { widget.set_data::(RECT_KEY, (left as i32, top as i32, w as i32, h as i32)) }; let Some(parent) = widget.parent() else { return }; if parent.downcast_ref::().is_none() { // First placement: out of the window box (where tauri packed it), into the overlay. let Ok(vbox) = parent.downcast::() else { return }; let Some(overlay) = vbox.children().into_iter().find_map(|child| child.downcast::().ok()) else { return; }; vbox.remove(&widget); overlay.add_overlay(&widget); if RAISED.load(Ordering::SeqCst) { overlay.reorder_overlay(&widget, 0); } } widget.queue_resize(); }); } } /// The event carrying a tab's last frame to the shell ([`capture_still`]). pub const EVENT_TAB_STILL: &str = "tab-still"; #[cfg(windows)] const EVENT_TAB_STILL_START: &str = "tab-still-start"; #[cfg(windows)] const EVENT_TAB_STILL_CANCEL: &str = "tab-still-cancel"; #[cfg(windows)] const EVENT_TAB_STILL_RESET: &str = "tab-still-reset"; #[cfg(windows)] #[derive(Clone, Serialize)] struct TabStill { id: String, /// `data:image/jpeg;base64,…` image: String, token: u64, } #[cfg(windows)] #[derive(Clone, Serialize)] struct TabStillToken { id: String, token: u64 } #[cfg(windows)] #[derive(Clone, Copy, PartialEq, Eq)] enum StillPhase { Pending, Hidden } #[cfg(windows)] #[derive(Clone, Copy)] struct StillEntry { token: u64, phase: StillPhase } #[cfg(windows)] impl StillEntry { fn accepts(self, token: u64) -> bool { self.token == token } fn hide(&mut self, token: u64) -> bool { if !self.accepts(token) || self.phase != StillPhase::Pending { return false; } self.phase = StillPhase::Hidden; true } } #[cfg(all(test, windows))] mod still_tests { use super::{cancel_pending_still, pending_stills, StillEntry, StillPhase}; #[test] fn timeout_keeps_late_frame_valid_but_stale_tokens_cannot_hide() { let mut entry = StillEntry { token: 7, phase: StillPhase::Pending }; assert!(!entry.hide(6)); assert!(entry.hide(7)); assert!(entry.accepts(7)); // CapturePreview may finish after the bounded hide. assert!(!entry.hide(7)); // A late acknowledgement must not hide a shown tab again. entry = StillEntry { token: 8, phase: StillPhase::Pending }; assert!(!entry.accepts(7)); assert!(!entry.hide(7)); } #[test] fn teardown_invalidates_old_capture_before_same_label_is_reused() { let label = "tab-still-test-recreated"; pending_stills().lock().unwrap().insert(label.into(), StillEntry { token: 31, phase: StillPhase::Hidden }); assert_eq!(cancel_pending_still(label), Some(31)); pending_stills().lock().unwrap().insert(label.into(), StillEntry { token: 32, phase: StillPhase::Pending }); let current = pending_stills().lock().unwrap().get(label).copied().unwrap(); assert!(!current.accepts(31)); assert!(current.accepts(32)); cancel_pending_still(label); } } #[cfg(windows)] fn pending_stills() -> &'static std::sync::Mutex> { use std::sync::{Mutex, OnceLock}; static PENDING: OnceLock>> = OnceLock::new(); PENDING.get_or_init(Default::default) } #[cfg(windows)] fn cancel_pending_still(label: &str) -> Option { pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(label).map(|entry| entry.token) } #[cfg(windows)] fn hide_after_still(app: &AppHandle, label: &str, token: u64) { let app = app.clone(); let label = label.to_owned(); let dispatcher = app.clone(); // Webview::show/hide both execute on Tauri's main thread. Check the token there, directly // before hiding, so a queued hide cannot run after a newer show canceled this capture. let _ = dispatcher.run_on_main_thread(move || { let current = { let mut pending = pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); pending.get_mut(&label).is_some_and(|entry| entry.hide(token)) }; if current { if let Some(tab) = app.get_webview(&label) { let _ = tab.hide(); } } }); } /// The shell has painted a captured frame; complete the bounded overlay handoff. #[cfg(windows)] pub fn acknowledge_still(app: &AppHandle, id: &str, token: u64) { if policy::valid_tab_id(id) { hide_after_still(app, &policy::tab_label(id), token); } } #[cfg(not(windows))] pub fn acknowledge_still(_app: &AppHandle, _id: &str, _token: u64) {} /// Windows: a tab webview about to be hidden hands the shell its last frame (WebView2 /// `CapturePreview`, JPEG) as [`EVENT_TAB_STILL`], which its pane shows while no webview is on /// screen there. Only a webview that is on screen now; requested before the hide, on the same /// thread, so the frame is still there. Kept in the shell's memory only. No-op elsewhere. fn capture_still(webview: &Webview, token: u64) { #[cfg(windows)] { let Some(id) = policy::tab_id(webview.label()).map(str::to_owned) else { return }; let label = webview.label().to_owned(); let app = webview.app_handle().clone(); let fallback = app.clone(); let fallback_label = label.clone(); let result = webview.with_webview(move |platform| { use base64::Engine; use webview2_com::CapturePreviewCompletedHandler; use webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_JPEG; use windows::Win32::System::Com::{STREAM_SEEK_END, STREAM_SEEK_SET}; use windows::Win32::UI::Shell::SHCreateMemStream; // SAFETY: COM calls on this webview's live controller and a memory stream it writes // into, on the main thread (with_webview and the completion handler run there). unsafe { let controller = platform.controller(); let mut shown = windows::core::BOOL::default(); if controller.IsVisible(&mut shown).is_err() || !shown.as_bool() { hide_after_still(&app, &label, token); return; } let (Ok(core), Some(stream)) = (controller.CoreWebView2(), SHCreateMemStream(None)) else { hide_after_still(&app, &label, token); return; }; let target = stream.clone(); let callback_app = app.clone(); let callback_label = label.clone(); let handler = CapturePreviewCompletedHandler::create(Box::new(move |result| { let image = (|| { result.ok()?; let mut size = 0u64; target.Seek(0, STREAM_SEEK_END, Some(&mut size)).ok()?; if size == 0 || size > 16 << 20 { return None; } target.Seek(0, STREAM_SEEK_SET, None).ok()?; let mut bytes = vec![0u8; size as usize]; let mut read = 0u32; target.Read(bytes.as_mut_ptr().cast(), size as u32, Some(&mut read)).ok().ok()?; bytes.truncate(read as usize); Some(format!("data:image/jpeg;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes))) })(); if let Some(image) = image { let current = pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner()) .get(&callback_label).is_some_and(|entry| entry.accepts(token)); if current { emit_shell(&callback_app, EVENT_TAB_STILL, TabStill { id, image, token }); } } else { hide_after_still(&callback_app, &callback_label, token); } Ok(()) })); if core.CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_JPEG, &stream, &handler).is_err() { hide_after_still(&app, &label, token); } } }); if result.is_err() { hide_after_still(&fallback, &fallback_label, token); } } #[cfg(not(windows))] let _ = (webview, token); } /// Shows or hides a tab webview and sets its WebView2 memory target to match /// ([`tabs::memory_target`]): hidden tabs run at `COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW` /// (ICoreWebView2_19, WebView2 Runtime 114+; older runtimes just skip it). Other platforms only /// show/hide. pub fn set_tab_visible(webview: &Webview, visible: bool, parked: bool, was_visible: bool) { #[cfg(windows)] { let app = webview.app_handle(); let id = policy::tab_id(webview.label()).unwrap_or_default().to_owned(); if let Some(token) = cancel_pending_still(webview.label()) { emit_shell(app, EVENT_TAB_STILL_CANCEL, TabStillToken { id: id.clone(), token }); } if !visible && !parked && was_visible && webview.window().is_visible().unwrap_or(false) { use std::sync::atomic::{AtomicU64, Ordering}; static NEXT: AtomicU64 = AtomicU64::new(1); let token = NEXT.fetch_add(1, Ordering::Relaxed); let label = webview.label().to_owned(); pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner()) .insert(label.clone(), StillEntry { token, phase: StillPhase::Pending }); emit_shell(app, EVENT_TAB_STILL_START, TabStillToken { id, token }); let app = app.clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(160)).await; hide_after_still(&app, &label, token); }); capture_still(webview, token); set_memory_target(webview, visible, parked); return; } } let _ = if visible { webview.show() } else { webview.hide() }; set_memory_target(webview, visible, parked); } fn set_memory_target(webview: &Webview, visible: bool, parked: bool) { let _ = (webview, visible, parked); #[cfg(windows)] { use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; // Last target per webview label: a sync that changes nothing costs no COM call. static LAST: OnceLock>> = OnceLock::new(); let target = tabs::memory_target(visible, parked); let mut last = LAST.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner()); if last.insert(webview.label().to_owned(), target) == Some(target) { return; } drop(last); let _ = webview.with_webview(move |platform| { use webview2_com::Microsoft::Web::WebView2::Win32::{ ICoreWebView2_19, COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW, COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL, }; use windows::core::Interface; let level = match target { tabs::MemoryTarget::Low => COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW, tabs::MemoryTarget::Normal => COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL, }; // SAFETY: COM calls on the live controller of this webview, on the main thread // (with_webview runs the closure there). unsafe { if let Ok(core) = platform.controller().CoreWebView2() { if let Ok(core) = core.cast::() { let _ = core.SetMemoryUsageTargetLevel(level); } } } }); } } fn placements() -> std::sync::MutexGuard<'static, crate::layout::Placements> { use std::sync::{Mutex, OnceLock}; static PLACEMENTS: OnceLock> = OnceLock::new(); PLACEMENTS.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } /// Places a tab webview and shows or hides it ([`place_child`], [`set_tab_visible`]), skipping the /// native calls for whatever did not change since its last placement: a sync that moves one pane /// touches one webview, and one that changes nothing costs nothing. pub fn place_tab(webview: &Webview, rect: crate::layout::Rect, visible: bool, parked: bool) { let (changed, was_visible) = { let mut placed = placements(); let was_visible = placed.was_visible(webview.label()); (placed.place(webview.label(), rect, visible), was_visible) }; if changed.bounds { let (x, y, width, height) = rect; place_child(webview, x, y, width, height); } if changed.visible { set_tab_visible(webview, visible, parked, was_visible); } else { set_memory_target(webview, visible, parked); } } /// A tab webview was closed: a new one with the same label is placed from scratch. pub fn forget_tab(app: &AppHandle, label: &str) { #[cfg(windows)] { cancel_pending_still(label); if let Some(id) = policy::tab_id(label) { emit_shell(app, EVENT_TAB_STILL_RESET, id.to_owned()); } } #[cfg(not(windows))] let _ = app; // Its find session went with the webview: a new one under the same id registers its own handlers. #[cfg(windows)] if let Some(id) = policy::tab_id(label) { find_windows::forget(id); } placements().forget(label); } /// The window size (logical px) and state a tab layout is clamped against. #[derive(Clone, Copy, Debug, PartialEq)] pub struct WindowMetrics { pub width: f64, pub height: f64, pub maximized: bool, pub frameless: bool, } fn metrics_cache() -> std::sync::MutexGuard<'static, Option> { use std::sync::{Mutex, OnceLock}; static METRICS: OnceLock>> = OnceLock::new(); METRICS.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } fn read_metrics(window: &tauri::Window) -> tauri::Result { let size = window.inner_size()?.to_logical::(window.scale_factor()?); Ok(WindowMetrics { width: size.width, height: size.height, maximized: window.is_maximized().unwrap_or(false), frameless: !window.is_decorated().unwrap_or(true), }) } /// The window's metrics, kept current by [`follow_window_resizes`]: a tab sync (off the main /// thread) then needs no blocking round trip to the event loop for them. pub fn window_metrics(window: &tauri::Window) -> tauri::Result { if let Some(metrics) = *metrics_cache() { return Ok(metrics); } let metrics = read_metrics(window)?; *metrics_cache() = Some(metrics); Ok(metrics) } /// The shell laid its panes out for a window of `viewport` (its own inner size); returns the /// window size to lay them out in now and whether the layout is stale (the window was resized /// since). Stale layouts are re-based with [`crate::layout::follow_rect`] by the caller. pub fn layout_window(metrics: &WindowMetrics, viewport: Option<(f64, f64)>) -> Option<(f64, f64)> { let (width, height) = viewport.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 1.0 && *h > 1.0)?; let stale = (width - metrics.width).abs() > 1.5 || (height - metrics.height).abs() > 1.5; stale.then_some((width, height)) } /// Records the window size the tab placements now correspond to. pub fn layout_placed(metrics: &WindowMetrics) { placements().set_window((metrics.width, metrics.height)); } /// Keeps tab webviews glued to the window while it is resized, maximized or snapped: on every /// resize (main thread, in the same event the runtime resizes the shell webview in) the visible /// tabs are re-placed from the last layout ([`crate::layout::Placements::follow`]), with no JS /// round trip. The shell's exact layout follows a frame or two later and replaces the estimate. /// Call once from `setup`, after the window is built. pub fn follow_window_resizes(window: &tauri::Window) { if let Ok(metrics) = read_metrics(window) { *metrics_cache() = Some(metrics); } let target = window.clone(); window.on_window_event(move |event| { if !matches!(event, tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. }) { return; } let Ok(metrics) = read_metrics(&target) else { return }; *metrics_cache() = Some(metrics); // Minimized: 0x0; nothing to follow, and the next restore re-bases from the last layout. if metrics.width < 2.0 || metrics.height < 2.0 { return; } let moved = placements().follow((metrics.width, metrics.height)); for (label, (x, y, width, height)) in moved { if let Some(webview) = target.app_handle().get_webview(&label) { // Never over the titlebar or the resize border, like every shell layout. let clamped = policy::clamp_tab_rect(x, y, width, height, metrics.width, metrics.height, metrics.maximized, metrics.frameless); if let Ok(Some((x, y, width, height))) = clamped { place_child(&webview, x, y, width, height); } } } }); }