waybetter/crates/waybetter-host/src/host.rs

1033 lines
46 KiB
Rust

//! 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<R: Runtime>(webview: &Webview<R>) -> 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<R: Runtime>(webview: &Webview<R>) -> Result<String, String> {
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<R: Runtime>(site: &SiteSpec, webview: &Webview<R>) -> Result<Url, String> {
let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?;
policy::require_site_tab(site, webview.label(), &url)?;
Ok(url)
}
pub fn emit_shell<R: Runtime, S: Serialize + Clone>(app: &AppHandle<R>, event: &str, payload: S) {
let _ = app.emit_to(EventTarget::webview(policy::SHELL_LABEL), event, payload);
}
pub fn emit_tab_state<R: Runtime>(app: &AppHandle<R>, state: TabState) {
emit_shell(app, tabs::EVENT_TAB_STATE, state);
}
pub fn tab_webview<R: Runtime>(app: &AppHandle<R>, id: &str) -> Option<Webview<R>> {
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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, name: &str) -> Option<std::path::PathBuf> {
app.path().app_data_dir().ok().map(|dir| dir.join(name))
}
/// `read_json::<T>(app, name)`: the app-data JSON file `name`, or None when missing or invalid.
pub fn read_json<T: serde::de::DeserializeOwned>(app: &AppHandle<impl Runtime>, name: &str) -> Option<T> {
serde_json::from_str(&std::fs::read_to_string(data_file(app, name)?).ok()?).ok()
}
pub fn write_json<R: Runtime, T: Serialize>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>) -> std::sync::MutexGuard<'static, crate::zoom::ZoomMap> {
use std::sync::{Mutex, OnceLock};
static ZOOMS: OnceLock<Mutex<crate::zoom::ZoomMap>> = OnceLock::new();
ZOOMS
.get_or_init(|| Mutex::new(read_json::<crate::zoom::ZoomMap>(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<R: Runtime>(app: &AppHandle<R>, id: &str, factor: Option<f64>) -> Result<f64, String> {
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<R: Runtime>(app: &AppHandle<R>, tab: &Webview<R>) {
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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<String>> {
static WATCHED: OnceLock<Mutex<HashSet<String>>> = 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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::<ICoreWebView2_28>(), platform.environment().cast::<ICoreWebView2Environment15>()) 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, id: String, phase: String, url: Option<String>) -> Result<(), String> {
let url = tabs::link_drag(&phase, url)?;
#[derive(Clone, Serialize)]
struct LinkDrag {
id: String,
phase: String,
url: Option<String>,
}
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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(webview: &Webview<R>, 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<R: Runtime>(window: &tauri::Window<R>) {
#[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<R: tauri::Runtime>(window: &tauri::Window<R>) {
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<R: Runtime>(webview: &Webview<R>, 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::<f64>(1.0), b.size.to_logical::<f64>(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<R: Runtime>(shell: &Webview<R>) {
#[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<R: Runtime>(shell: &Webview<R>, 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<R: Runtime>(shell: &Webview<R>, 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::<NSView>() };
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::<NSView>().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<R: Runtime>(webview: &Webview<R>) -> 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<String, Rect>> {
static RECTS: OnceLock<Mutex<HashMap<String, Rect>>> = OnceLock::new();
RECTS.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn placed(label: &str) -> Option<Rect> {
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<R: Runtime>(shell: &Webview<R>, on_top: bool) {
RAISED.store(on_top, Ordering::SeqCst);
let _ = shell.with_webview(move |platform| {
let widget = platform.inner().upcast::<gtk::Widget>();
let Some(overlay) = widget.parent().and_then(|parent| parent.downcast::<gtk::Overlay>().ok()) else { return };
overlay.reorder_overlay(&widget, if on_top { -1 } else { 0 });
});
}
pub fn attach_shell<R: Runtime>(shell: &Webview<R>) {
let _ = shell.with_webview(|platform| {
let widget = platform.inner().upcast::<gtk::Widget>();
let Some(vbox) = widget.parent().and_then(|parent| parent.downcast::<gtk::Box>().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::<GtkRect>(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(&gtk::Box::new(gtk::Orientation::Vertical, 0));
overlay.add_overlay(&widget);
vbox.pack_start(&overlay, true, true, 0);
overlay.show();
});
}
pub fn place<R: Runtime>(webview: &Webview<R>, 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::<gtk::Widget>();
// SAFETY: read back only as `GtkRect` (the overlay's get-child-position handler).
unsafe { widget.set_data::<GtkRect>(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::<gtk::Overlay>().is_none() {
// First placement: out of the window box (where tauri packed it), into the overlay.
let Ok(vbox) = parent.downcast::<gtk::Box>() else { return };
let Some(overlay) = vbox.children().into_iter().find_map(|child| child.downcast::<gtk::Overlay>().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<std::collections::HashMap<String, StillEntry>> {
use std::sync::{Mutex, OnceLock};
static PENDING: OnceLock<Mutex<std::collections::HashMap<String, StillEntry>>> = OnceLock::new();
PENDING.get_or_init(Default::default)
}
#[cfg(windows)]
fn cancel_pending_still(label: &str) -> Option<u64> {
pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(label).map(|entry| entry.token)
}
#[cfg(windows)]
fn hide_after_still<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(_app: &AppHandle<R>, _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<R: Runtime>(webview: &Webview<R>, 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<R: Runtime>(webview: &Webview<R>, 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<R: Runtime>(webview: &Webview<R>, 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<Mutex<HashMap<String, tabs::MemoryTarget>>> = 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::<ICoreWebView2_19>() {
let _ = core.SetMemoryUsageTargetLevel(level);
}
}
}
});
}
}
fn placements() -> std::sync::MutexGuard<'static, crate::layout::Placements> {
use std::sync::{Mutex, OnceLock};
static PLACEMENTS: OnceLock<Mutex<crate::layout::Placements>> = 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<R: Runtime>(webview: &Webview<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<WindowMetrics>> {
use std::sync::{Mutex, OnceLock};
static METRICS: OnceLock<Mutex<Option<WindowMetrics>>> = OnceLock::new();
METRICS.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn read_metrics<R: Runtime>(window: &tauri::Window<R>) -> tauri::Result<WindowMetrics> {
let size = window.inner_size()?.to_logical::<f64>(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<R: Runtime>(window: &tauri::Window<R>) -> tauri::Result<WindowMetrics> {
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<R: Runtime>(window: &tauri::Window<R>) {
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);
}
}
}
});
}