1033 lines
46 KiB
Rust
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(>k::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);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|