host: run_app — the window, shell, tab webviews and generic commands for every app

The `app` feature (App / run_app, AppConfig, SiteHooks): registers the Tauri plugins every app
uses (single-instance, deep-link and updater opt-in, window-state, opener, process, notification,
dialog), makes app data, loads profiles and the language, builds the window (restore, lost-geometry
reset, helper-close routing, native resize follow) and the transparent shell, and serves tabs_sync,
tab_navigate, tab_action, open_external_url, shell_focus, shell_raise, window_system_menu,
profiles_get/set, i18n_get/set (shell-only) and tab_shortcut, tab_link_drag (tab-only). The tab
builder is generic; the app fills in SiteHooks. `commands` lists them (manifest() for build.rs),
`testkit` checks capability files and caller guards, i18n::page_script is origin-guarded.
check:tauri now checks the app feature.
This commit is contained in:
CRTkafa 2026-09-29 11:51:24 +07:00
parent 8c93fc470d
commit d051762359
8 changed files with 1616 additions and 45 deletions

View file

@ -0,0 +1,869 @@
//! The whole wayBetter app around one site: [`run_app`] (or [`App`]) makes the window, the trusted
//! local shell webview and one child webview per tab, registers the Tauri plugins every app uses,
//! and serves the generic commands ([`crate::commands`]): tab layout and actions, the shell's
//! focus and raise, external links, the window menu, account profiles and the language.
//!
//! The app hands it an [`AppConfig`] (its site, window title and size, which plugins it
//! configures) and a [`SiteHooks`] implementation for what only it knows (the page scripts of a
//! new tab, what happens when a site page loads, its own per-tab state), plus its own commands.
//!
//! ```ignore
//! pub fn run() {
//! waybetter_host::app::App::new(CONFIG, MySite)
//! .setup(|app| { app.manage(MyState::load(app)); Ok(()) })
//! .run(tauri::generate_context!(), tauri::generate_handler![my_command]);
//! }
//! ```
use crate::{
commands,
host::{self, emit_shell, emit_tab_state, read_json, shell_only, tab_only, tab_webview, write_json},
i18n::{self, NativeStrings},
layout, policy,
profiles::{self, AccountSeen, ProfileSettings},
site::SiteSpec,
tabs::{self, parse_web_url, OpenRequest, TabLayout, TabState},
};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use tauri::webview::{DownloadEvent, NewWindowResponse, PageLoadEvent};
use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, Webview, WebviewBuilder, WebviewUrl, Wry};
use tauri_plugin_opener::OpenerExt;
use url::Url;
/// The account profiles (app data), see [`profiles`].
pub const PROFILES_FILE: &str = "profiles.json";
/// The owner's language and the strings native code needs (app data), see [`i18n`].
pub const I18N_FILE: &str = "i18n.json";
/// What differs between wayBetter apps at the window level.
#[derive(Clone, Copy, Debug)]
pub struct AppConfig {
pub site: &'static SiteSpec,
/// Window title (and the name in "failed to launch" messages).
pub title: &'static str,
/// A previous bundle identifier whose data folders are moved over once ([`host::migrate_identifier`]).
pub legacy_identifier: Option<&'static str>,
/// First-launch window size and the smallest size, in logical px.
pub window_size: (f64, f64),
pub min_window_size: (f64, f64),
/// The window's color before the shell paints (no white flash).
pub background: (u8, u8, u8),
/// The shell draws the titlebar (Windows/Linux: frameless; macOS: an overlay titlebar with the
/// traffic lights over the shell). Off: the OS frame, for a shell without one.
pub custom_titlebar: bool,
/// Register the deep-link plugin (`plugins.deep-link` in tauri.conf.json: install links).
pub deep_link: bool,
/// Register the updater plugin (`plugins.updater` in tauri.conf.json: endpoint and key).
pub updater: bool,
/// The language strings site tabs receive (key prefixes, e.g. `["x.", "kit.", "plugin."]`).
pub page_string_prefixes: &'static [&'static str],
}
impl AppConfig {
/// The defaults: 1360x900 (at least 900x560), a near-black background, the shell's own
/// titlebar, no deep links or updater, the UI kit's and plugins' strings for site tabs.
pub const fn new(site: &'static SiteSpec, title: &'static str) -> Self {
Self {
site,
title,
legacy_identifier: None,
window_size: (1360.0, 900.0),
min_window_size: (900.0, 560.0),
background: (7, 8, 12),
custom_titlebar: true,
deep_link: false,
updater: false,
page_string_prefixes: &["kit.", "plugin."],
}
}
}
/// A tab webview about to be created ([`SiteHooks::init_scripts`]).
pub struct NewTab<'a> {
pub id: &'a str,
/// `tab-<id>`
pub label: &'a str,
/// Its account profile (fixed for the webview's life).
pub profile: &'a str,
/// The shell's layout entry it is created for (role, navigation rail, ...).
pub item: &'a TabLayout,
}
/// Why a tab webview was closed ([`SiteHooks::tab_closed`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabClosed {
/// Its tab moved to another account profile: a fresh webview replaces it.
Replaced,
/// Its tab is gone.
Removed,
}
/// What only the app knows. Every method has a harmless default except the page scripts.
/// Called without any host lock held, so an implementation may use the host's helpers.
pub trait SiteHooks: Send + Sync + 'static {
/// The document-start scripts of a new tab webview, in order, the tab bridge included
/// (`waybetter_host::bridge`), then the app's page runtime.
fn init_scripts(&self, app: &AppHandle, tab: &NewTab) -> Vec<String>;
/// The new tab webview's background until the page paints.
fn tab_background(&self, _app: &AppHandle, _label: &str) -> Option<tauri::window::Color> {
None
}
/// The URL a navigation to `url` should load instead (legacy domains), if any.
fn canonical_url(&self, _url: &Url) -> Option<Url> {
None
}
/// Pages that stay "on the site" for link routing besides its exact origin (login routes).
fn site_navigation(&self, _url: &Url) -> bool {
false
}
/// A navigation starts in tab `label`. Returns whether links to other sites go to the
/// system browser (the owner's setting), or None when the app's state is unavailable (the
/// navigation is refused).
fn navigation_started(&self, _app: &AppHandle, _label: &str) -> Option<bool> {
Some(false)
}
/// Where a new-window request (target=_blank, window.open) for `url` opens: "tab", "pane" or
/// "browser".
fn new_window_mode(&self, _app: &AppHandle, _url: &Url) -> String {
"tab".into()
}
/// A new document started loading in `tab`.
fn page_started(&self, _app: &AppHandle, _tab: &Webview) {}
/// A document on the site finished loading in `tab` (evaluate settings, plugins, themes).
fn site_page_loaded(&self, _app: &AppHandle, _tab: &Webview, _url: &Url) {}
/// A tab webview was created.
fn tab_created(&self, _app: &AppHandle) {}
/// `tab` was laid out for `item` (existing or new), before it is placed.
fn tab_placed(&self, _app: &AppHandle, _tab: &Webview, _item: &TabLayout) {}
/// Every tab's placement after a layout: (id, rect, visible).
fn layout_changed(&self, _app: &AppHandle, _layout: &[(String, layout::Rect, bool)]) {}
/// A tab webview was closed: forget the app's per-tab state.
fn tab_closed(&self, _app: &AppHandle, _label: &str, _why: TabClosed) {}
/// The shell was raised above the tabs (`on`) or lowered again.
fn shell_raised(&self, _app: &AppHandle, _on: bool) {}
}
/// The account profiles and which profile each open tab webview belongs to.
#[derive(Debug, Default)]
pub struct ProfileState {
pub settings: ProfileSettings,
/// Tab webview label -> the profile its WebView data directory belongs to.
pub tab_profiles: HashMap<String, String>,
}
struct Profiles(Mutex<ProfileState>);
struct Strings(Mutex<NativeStrings>);
struct Host {
config: AppConfig,
hooks: Arc<dyn SiteHooks>,
}
fn host_state(app: &AppHandle) -> tauri::State<'_, Host> {
app.state::<Host>()
}
/// The app's config ([`run_app`] must be running).
pub fn config(app: &AppHandle) -> AppConfig {
host_state(app).config
}
/// The profiles lock. Never call an app hook while holding it.
pub fn profiles(app: &AppHandle) -> Result<MutexGuard<'_, ProfileState>, String> {
app.try_state::<Profiles>()
.ok_or("DATABASE: workspace state unavailable; restart safely")?
.inner()
.0
.lock()
.map_err(|_| "DATABASE: workspace state unavailable; restart safely".into())
}
/// The profile tab webview `label` belongs to.
pub fn tab_profile(app: &AppHandle, label: &str) -> Option<String> {
profiles(app).ok()?.tab_profiles.get(label).cloned()
}
/// A tab reported the account the site shows in it. Recorded only for that webview's own profile
/// (validated by the site's [`crate::site::AccountRules`]); the shell hears about a change. The
/// caller checks the webview is on the site right now.
pub fn account_seen(app: &AppHandle, label: &str, seen: AccountSeen) -> Result<(), String> {
let site = config(app).site;
let changed = {
let mut data = profiles(app)?;
let profile = data.tab_profiles.get(label).cloned();
match profile {
Some(profile) if data.settings.observe(&profile, seen, &site.accounts) => Some(data.settings.clone()),
_ => None,
}
};
if let Some(settings) = changed {
write_json(app, PROFILES_FILE, &settings)?;
emit_shell(app, tabs::EVENT_PROFILES_CHANGED, settings);
}
Ok(())
}
/// Opens an http(s) or mailto link in the system browser / mail app; other schemes are dropped.
pub fn open_external(app: &AppHandle, url: &Url) {
if matches!(url.scheme(), "http" | "https" | "mailto") {
let _ = app.opener().open_url(url.as_str(), None::<&str>);
}
}
/// Shows a native OS notification under the app's name.
pub fn notify(app: &AppHandle, title: &str, body: &str) {
crate::alerts::notify(app, config(app).site.product, title, body);
}
fn with_strings<T>(app: &AppHandle, f: impl FnOnce(&NativeStrings) -> T) -> Option<T> {
let state = app.try_state::<Strings>()?;
let strings = state.0.lock().ok()?;
Some(f(&strings))
}
/// The native string `key` in the owner's language, or the compiled-in English `fallback`.
pub fn tr(app: &AppHandle, key: &str, fallback: &str) -> String {
with_strings(app, |strings| strings.get(key, fallback).to_owned()).unwrap_or_else(|| fallback.to_owned())
}
/// [`tr`] with `{name}` placeholders filled in (values are plain text).
pub fn trf(app: &AppHandle, key: &str, fallback: &str, params: &[(&str, &str)]) -> String {
with_strings(app, |strings| strings.format(key, fallback, params))
.unwrap_or_else(|| NativeStrings::default().format(key, fallback, params))
}
/// The script handing a site tab its strings ([`AppConfig::page_string_prefixes`]), guarded to
/// the site's origin ([`i18n::page_script`]). Evaluate it at document start and on every load.
pub fn i18n_page_script(app: &AppHandle) -> String {
let config = config(app);
let payload = with_strings(app, |strings| strings.page_payload(config.page_string_prefixes))
.unwrap_or_else(|| serde_json::json!({ "locale": "en", "dir": "ltr", "messages": {} }));
i18n::page_script(config.site, &payload)
}
/// Whether the shell webview sits above the tab webviews (one of its overlays is open).
static SHELL_RAISED: AtomicBool = AtomicBool::new(false);
/// Whether the shell is raised above the tabs right now (`shell_raise`).
pub fn shell_is_raised() -> bool {
SHELL_RAISED.load(Ordering::SeqCst)
}
fn in_site(site: &SiteSpec, hooks: &dyn SiteHooks, url: &Url) -> bool {
site.is_origin(url) || hooks.site_navigation(url)
}
// ---------------------------------------------------------------------------------------------
// Commands (commands::SHELL, commands::TAB). Each checks its caller first (testkit::assert_host_guards).
/// Reconciles native tab webviews with the shell's panes: creates missing ones, positions and
/// shows/hides all of them, and closes webviews whose tab no longer exists.
#[tauri::command]
async fn tabs_sync(webview: Webview, app: AppHandle, items: Vec<TabLayout>, viewport: Option<[f64; 2]>) -> Result<(), String> {
shell_only(&webview)?;
let hooks = host_state(&app).hooks.clone();
let window = webview.window();
// Kept current by the window's resize events (host::follow_window_resizes): no blocking
// round trips to the event loop on every frame of a drag.
let metrics = host::window_metrics(&window).map_err(|error| format!("TAB: {error}"))?;
let win_size = LogicalSize::new(metrics.width, metrics.height);
let (maximized, frameless) = (metrics.maximized, metrics.frameless);
// Laid out for a window size that is already gone (the window was resized while this was in
// flight): re-based onto the current size like a native resize follow, never shown stale.
let rebase = host::layout_window(&metrics, viewport.map(|[width, height]| (width, height))).and_then(|old| {
let visible: Vec<_> = items.iter().filter(|item| item.visible).map(|item| (item.x, item.y, item.width, item.height)).collect();
layout::Frame::around(&visible, old).map(|frame| (old, frame))
});
let mut keep = HashSet::new();
let mut placed = Vec::new();
for item in items {
if !policy::valid_tab_id(&item.id) {
return Err("VALIDATION: invalid tab id".into());
}
let label = policy::tab_label(&item.id);
let current_profile = {
let data = profiles(&app)?;
if !profiles::valid_profile_id(&item.profile) || !data.settings.contains(&item.profile) {
return Err("VALIDATION: unknown profile".into());
}
data.tab_profiles.get(&label).cloned()
};
keep.insert(item.id.clone());
let (item_x, item_y, item_width, item_height) = match rebase {
Some((old, frame)) => layout::follow_rect((item.x, item.y, item.width, item.height), old, (metrics.width, metrics.height), frame),
None => (item.x, item.y, item.width, item.height),
};
let rect = policy::clamp_tab_rect(item_x, item_y, item_width, item_height, win_size.width, win_size.height, maximized, frameless)
.map_err(|error| error.to_string())?;
// Nothing left after clamping: keep (or create) the tab, but hidden.
let visible = item.visible && rect.is_some();
let (x, y, width, height) = rect.unwrap_or((0.0, policy::TITLEBAR_H, 1.0, 1.0));
let existing = tab_webview(&app, &item.id);
// A webview can never switch data directories: a profile change means a fresh webview,
// and the app's per-tab state goes with the old one.
let existing = match existing {
Some(tab) if current_profile.as_deref() != Some(item.profile.as_str()) => {
let _ = tab.close();
host::forget_tab(&label);
profiles(&app)?.tab_profiles.remove(&label);
hooks.tab_closed(&app, &label, TabClosed::Replaced);
None
}
other => other,
};
let tab = match existing {
Some(tab) => tab,
None => {
let url = parse_web_url(&item.url)?;
let data_dir = app.path().app_local_data_dir().ok().and_then(|local| profiles::data_dir(&local, &item.profile));
if item.profile != profiles::DEFAULT_PROFILE && data_dir.is_none() {
return Err("PROFILE: no local data directory".into());
}
// Reserve the profile before the webview exists, re-checked under the lock that
// profiles_set holds, so a concurrent removal cannot delete its folder under it.
{
let mut data = profiles(&app)?;
if !data.settings.contains(&item.profile) {
return Err("VALIDATION: unknown profile".into());
}
data.tab_profiles.insert(label.clone(), item.profile.clone());
}
if let Some(dir) = &data_dir {
if let Err(error) = std::fs::create_dir_all(dir) {
profiles(&app)?.tab_profiles.remove(&label);
return Err(format!("PROFILE: {error}"));
}
}
let new_tab = NewTab { id: &item.id, label: &label, profile: &item.profile, item: &item };
let builder = tab_builder(&app, &hooks, &new_tab, url, data_dir);
// The site's page color until it paints (never the WebView's white).
let builder = match hooks.tab_background(&app, &label) {
Some(color) => builder.background_color(color),
None => builder,
};
match window.add_child(builder, LogicalPosition::new(x, y), LogicalSize::new(width, height)) {
Ok(tab) => {
hooks.tab_created(&app);
// A new child webview starts on top: keep a raised shell above it.
if shell_is_raised() {
host::raise_shell(&webview, true);
}
tab
}
Err(error) => {
profiles(&app)?.tab_profiles.remove(&label);
return Err(format!("TAB: {error}"));
}
}
}
};
hooks.tab_placed(&app, &tab, &item);
host::place_tab(&tab, (x, y, width, height), visible, item.parked || rect.is_none());
placed.push((item.id.clone(), (x, y, width, height), visible));
}
host::layout_placed(&metrics);
hooks.layout_changed(&app, &placed);
for existing in window.webviews() {
if let Some(id) = policy::tab_id(existing.label()) {
if !keep.contains(id) {
let label = existing.label().to_owned();
let _ = existing.close();
host::forget_tab(&label);
if let Ok(mut data) = profiles(&app) {
data.tab_profiles.remove(&label);
}
hooks.tab_closed(&app, &label, TabClosed::Removed);
}
}
}
Ok(())
}
/// 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.
#[tauri::command]
fn window_system_menu(webview: Webview, x: f64, y: f64) -> Result<(), String> {
shell_only(&webview)?;
host::system_menu(&webview, x, y)
}
#[tauri::command]
async fn tab_navigate(webview: Webview, app: AppHandle, id: String, url: String) -> Result<(), String> {
shell_only(&webview)?;
host::navigate_tab(&app, &id, &url)
}
/// back | forward | reload | stop | focus
#[tauri::command]
async fn tab_action(webview: Webview, app: AppHandle, id: String, action: String) -> Result<(), String> {
shell_only(&webview)?;
host::tab_action(&app, &id, &action)
}
/// Gives keyboard focus back to the shell (e.g. Ctrl+L pressed inside a tab).
#[tauri::command]
async fn shell_focus(webview: Webview) -> Result<(), String> {
shell_only(&webview)?;
webview.set_focus().map_err(|error| format!("FOCUS: {error}"))
}
/// Raises the shell above the tab webviews while one of its overlays (menu, dialog, palette,
/// drag) is open, so the panes stay on screen under it, or lowers it again. Returns whether this
/// platform can; when not, the shell hides the tabs for the overlay instead.
#[tauri::command]
async fn shell_raise(webview: Webview, app: AppHandle, on: bool) -> Result<bool, String> {
shell_only(&webview)?;
if on {
host_state(&app).hooks.shell_raised(&app, on);
}
SHELL_RAISED.store(on, Ordering::SeqCst);
Ok(host::raise_shell(&webview, on))
}
#[tauri::command]
async fn open_external_url(webview: Webview, app: AppHandle, url: String) -> Result<(), String> {
shell_only(&webview)?;
let url = parse_web_url(&url)?;
app.opener().open_url(url.as_str(), None::<&str>).map_err(|error| format!("OPEN: {error}"))
}
#[tauri::command]
async fn profiles_get(webview: Webview, app: AppHandle) -> Result<ProfileSettings, String> {
shell_only(&webview)?;
Ok(profiles(&app)?.settings.clone())
}
/// Saves the profile list (add, rename, recolor, switch active, remove). A removed profile must
/// have no open tab; its WebView data directory (that account's login) is then deleted.
#[tauri::command]
async fn profiles_set(webview: Webview, app: AppHandle, settings: ProfileSettings) -> Result<ProfileSettings, String> {
shell_only(&webview)?;
let site = config(&app).site;
let settings = settings.validated(&site.accounts)?;
// One lock for check + save + swap: tabs_sync reserves a tab's profile under the same lock,
// so no tab can start in a profile between this check and its data folder being deleted.
let (settings, removed) = {
let mut data = profiles(&app)?;
// Accounts are what the tabs saw, never what the shell sends.
let settings = settings.keep_accounts_from(&data.settings);
let removed = data.settings.removal(&settings, data.tab_profiles.values())?;
write_json(&app, PROFILES_FILE, &settings)?;
data.settings = settings.clone();
(settings, removed)
};
host::remove_profile_dirs(&app, site, &removed);
Ok(settings)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LanguageInfo {
/// `site`, `system`, a locale tag, or empty before the shell ever chose.
preference: String,
locale: String,
/// The OS language preferences, most preferred first.
system: Vec<String>,
}
/// The saved language choice and the OS languages.
#[tauri::command]
async fn i18n_get(webview: Webview, app: AppHandle) -> Result<LanguageInfo, String> {
shell_only(&webview)?;
let (preference, locale) = with_strings(&app, |strings| (strings.preference.clone(), strings.locale.clone())).unwrap_or_default();
Ok(LanguageInfo { preference, locale, system: i18n::system_locales() })
}
/// Shell only: the owner's language and the strings native code and site tabs show in it
/// (validated plain text; site tabs get only [`AppConfig::page_string_prefixes`], as data).
#[tauri::command]
async fn i18n_set(webview: Webview, app: AppHandle, strings: NativeStrings) -> Result<(), String> {
shell_only(&webview)?;
let config = config(&app);
let strings = strings.validated()?;
write_json(&app, I18N_FILE, &strings)?;
let script = i18n::page_script(config.site, &strings.page_payload(config.page_string_prefixes));
{
let state = app.state::<Strings>();
*state.0.lock().map_err(|_| "I18N: language state unavailable")? = strings;
}
host::eval_in_site_tabs(&app, config.site, &script);
Ok(())
}
/// Browser shortcuts pressed while a tab has keyboard focus, forwarded to the shell.
#[tauri::command]
async fn tab_shortcut(webview: Webview, app: AppHandle, shortcut: String) -> Result<(), String> {
let id = tab_only(&webview)?;
host::forward_shortcut(&app, id, shortcut)
}
/// A link drag started in this tab (`start`, with the dragged http(s) link), an external link drag
/// entered it (`enter`), or this tab's drag ended (`end`). The shell then hides the tab webviews
/// and shows its drop zones; the link only ever opens as a new tab, like `window.open`.
#[tauri::command]
async fn tab_link_drag(webview: Webview, app: AppHandle, phase: String, url: Option<String>) -> Result<(), String> {
let id = tab_only(&webview)?;
host::forward_link_drag(&app, id, phase, url)
}
// ---------------------------------------------------------------------------------------------
// Tab webviews
/// Linux: the page-level part of a tab's `on_navigation`, run when the main frame starts loading
/// `url` (WebKitGTK's navigation callback cannot tell iframes from the page). Returns false when
/// the load was redirected (a legacy domain) or stopped and handed to the browser.
fn main_frame_started(app: &AppHandle, site: &SiteSpec, hooks: &dyn SiteHooks, tab: &Webview, url: &Url) -> bool {
static ON_SITE: std::sync::OnceLock<Mutex<HashMap<String, bool>>> = std::sync::OnceLock::new();
if let Some(site_url) = hooks.canonical_url(url) {
let tab = tab.clone();
std::thread::spawn(move || {
let _ = tab.navigate(site_url);
});
return false;
}
let Some(external_browser) = hooks.navigation_started(app, tab.label()) else { return false };
let mut on_site = ON_SITE.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let was_on_site = on_site.get(tab.label()).copied().unwrap_or(false);
if external_browser && was_on_site && matches!(url.scheme(), "http" | "https") && !in_site(site, hooks, url) {
open_external(app, url);
let _ = tab.eval("window.stop()");
return false;
}
on_site.insert(tab.label().to_owned(), site.is_origin(url));
true
}
/// A new tab webview: its profile's data directory, the app's document-start scripts, and the
/// navigation, pop-up, load, title and download handling every wayBetter tab gets.
fn tab_builder(app: &AppHandle, hooks: &Arc<dyn SiteHooks>, tab: &NewTab, url: Url, data_dir: Option<PathBuf>) -> WebviewBuilder<Wry> {
let site = config(app).site;
let tab_id = tab.id.to_owned();
let (nav_app, nav_id, nav_hooks) = (app.clone(), tab_id.clone(), hooks.clone());
let (new_app, new_id, new_hooks) = (app.clone(), tab_id.clone(), hooks.clone());
let (load_app, load_id, load_hooks) = (app.clone(), tab_id.clone(), hooks.clone());
let (title_app, title_id) = (app.clone(), tab_id);
let mut builder = WebviewBuilder::new(tab.label, WebviewUrl::External(url));
// Non-default profiles get their own cookie/storage jar; the default keeps the existing one.
if let Some(dir) = data_dir {
builder = builder.data_directory(dir);
#[cfg(target_os = "macos")]
{
builder = builder.data_store_identifier(profiles::store_identifier(tab.profile));
}
}
for script in hooks.init_scripts(app, tab) {
builder = builder.initialization_script(script);
}
builder
// HTML5 drag and drop in the page: link drags out of it (split/tab drop zones in the shell)
// and the site's own file drops, instead of wry's OS file-drop handler (which refuses links).
.disable_drag_drop_handler()
.on_navigation(move |url| {
let app = &nav_app;
if !matches!(url.scheme(), "http" | "https") {
// about:blank and similar internal documents are fine; other schemes go to the OS.
if url.scheme() == "about" {
return true;
}
// WebKitGTK: possibly an iframe's own document (see below).
if cfg!(target_os = "linux") && matches!(url.scheme(), "blob" | "data") {
return true;
}
open_external(app, url);
return false;
}
// WebKitGTK asks this for iframe navigations too, without saying which frame (WebView2
// asks only for the page): a signed-out page embedding a sign-in iframe would get that
// iframe's URL as the tab's address. On Linux the page-level work below runs from
// on_page_load (Started), which is the main frame only.
if cfg!(target_os = "linux") {
return true;
}
if let Some(site_url) = nav_hooks.canonical_url(url) {
if let Some(tab) = tab_webview(app, &nav_id) {
std::thread::spawn(move || {
let _ = tab.navigate(site_url);
});
}
return false;
}
let Some(external_browser) = nav_hooks.navigation_started(app, &policy::tab_label(&nav_id)) else { return false };
let on_site = tab_webview(app, &nav_id).and_then(|tab| tab.url().ok()).is_some_and(|current| site.is_origin(&current));
if external_browser && on_site && !in_site(site, &*nav_hooks, url) {
open_external(app, url);
return false;
}
emit_tab_state(app, TabState { id: nav_id.clone(), url: Some(url.to_string()), loading: Some(true), ..Default::default() });
true
})
.on_new_window(move |url, _| {
let app = &new_app;
if !matches!(url.scheme(), "http" | "https") {
open_external(app, &url);
return NewWindowResponse::Deny;
}
let url = new_hooks.canonical_url(&url).unwrap_or(url);
let mode = new_hooks.new_window_mode(app, &url);
if mode == "browser" {
open_external(app, &url);
} else {
emit_shell(
app,
tabs::EVENT_TAB_OPEN,
OpenRequest { url: url.to_string(), from: Some(new_id.clone()), background: false, beside: mode == "pane" },
);
}
NewWindowResponse::Deny
})
.on_page_load(move |tab, payload| {
let app = &load_app;
if cfg!(target_os = "linux")
&& matches!(payload.event(), PageLoadEvent::Started)
&& !main_frame_started(app, site, &*load_hooks, &tab, payload.url())
{
return;
}
let finished = matches!(payload.event(), PageLoadEvent::Finished);
if !finished {
load_hooks.page_started(app, &tab);
}
emit_tab_state(app, TabState { id: load_id.clone(), url: Some(payload.url().to_string()), loading: Some(!finished), ..Default::default() });
if finished && site.is_origin(payload.url()) {
load_hooks.site_page_loaded(app, &tab, payload.url());
}
})
.on_document_title_changed(move |_, title| {
emit_tab_state(&title_app, TabState { id: title_id.clone(), title: Some(title), ..Default::default() });
})
.on_download(|_, event| match event {
// Ordinary browser downloads land in the user's Downloads folder.
DownloadEvent::Requested { .. } => true,
_ => true,
})
}
// ---------------------------------------------------------------------------------------------
// The app
/// What the window-state plugin saves and restores: never DECORATIONS (a state file from a framed
/// build would re-enable the OS frame) or VISIBLE (the window is shown once it is placed).
pub fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
use tauri_plugin_window_state::StateFlags;
StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE
}
/// A window state saved while the window was minimized (Windows parks it at -32000,-32000 with a
/// caption-sized rectangle) or on a monitor that is gone comes back as a tiny, off-screen window
/// in which nothing works. Such geometry is replaced by `size`, centered.
pub fn reset_lost_geometry(window: &tauri::Window, size: (f64, f64), min: (f64, f64)) {
let Ok(scale) = window.scale_factor() else { return };
let too_small = window
.inner_size()
.map(|size| size.to_logical::<f64>(scale))
.is_ok_and(|size| size.width < min.0 || size.height < min.1);
let off_screen = match (window.outer_position(), window.outer_size(), window.available_monitors()) {
(Ok(position), Ok(size), Ok(monitors)) => !monitors.iter().any(|monitor| {
let (mp, ms) = (monitor.position(), monitor.size());
// At least a 120 px square of the window must be on some monitor.
let overlap_x = (position.x + size.width as i32).min(mp.x + ms.width as i32) - position.x.max(mp.x);
let overlap_y = (position.y + size.height as i32).min(mp.y + ms.height as i32) - position.y.max(mp.y);
overlap_x >= 120 && overlap_y >= 120
}),
_ => false,
};
if too_small || off_screen {
let _ = window.set_size(LogicalSize::new(size.0, size.1));
let _ = window.center();
}
}
/// The host's commands ([`commands::SHELL`], [`commands::TAB`]).
fn host_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![
tabs_sync,
tab_navigate,
tab_action,
open_external_url,
shell_focus,
shell_raise,
window_system_menu,
profiles_get,
profiles_set,
i18n_get,
i18n_set,
tab_shortcut,
tab_link_drag
]
}
type Setup = Box<dyn FnOnce(&AppHandle) -> Result<(), Box<dyn std::error::Error>> + Send>;
/// The app builder: config, site hooks, the app's own setup, then [`App::run`].
pub struct App {
config: AppConfig,
hooks: Arc<dyn SiteHooks>,
setup: Option<Setup>,
}
impl App {
pub fn new(config: AppConfig, hooks: impl SiteHooks) -> Self {
Self { config, hooks: Arc::new(hooks), setup: None }
}
/// The app's own setup (open its database, `manage` its state), run after the host's (app
/// data folder, profiles, language) and before the window exists.
pub fn setup(mut self, setup: impl FnOnce(&AppHandle) -> Result<(), Box<dyn std::error::Error>> + Send + 'static) -> Self {
self.setup = Some(Box::new(setup));
self
}
/// Runs the app: `context` is `tauri::generate_context!()` and `handler` the app's own
/// `tauri::generate_handler![...]` (the host serves [`commands::SHELL`] and [`commands::TAB`]).
pub fn run<H>(self, context: tauri::Context<Wry>, handler: H)
where
H: Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static,
{
let App { config, hooks, setup } = self;
let host_handler = host_handler();
let builder = tauri::Builder::<Wry>::default()
// Must be first: a second launch focuses the running window instead of fighting over
// the app's data and WebView profile (and hands over its link).
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}));
// Install links (after single-instance, which forwards a second launch's link).
let builder = if config.deep_link { builder.plugin(tauri_plugin_deep_link::init()) } else { builder };
let builder = builder
.plugin(tauri_plugin_window_state::Builder::default().with_state_flags(window_state_flags()).build())
.plugin(tauri_plugin_opener::init());
// No capability grants the updater/process JS APIs; only Rust drives them.
let builder = if config.updater { builder.plugin(tauri_plugin_updater::Builder::new().build()) } else { builder };
builder
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init())
.setup(move |tauri_app| {
let handle = tauri_app.handle().clone();
let site = config.site;
if let Some(legacy) = config.legacy_identifier {
host::migrate_identifier(&handle, site, legacy);
}
let directory = tauri_app.path().app_data_dir()?;
std::fs::create_dir_all(&directory)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?;
}
let settings = read_json::<ProfileSettings>(&handle, PROFILES_FILE)
.and_then(|settings| settings.validated(&site.accounts).ok())
.unwrap_or_default();
host::prune_profile_dirs(&handle, &settings);
tauri_app.manage(Profiles(Mutex::new(ProfileState { settings, tab_profiles: HashMap::new() })));
let strings = read_json::<NativeStrings>(&handle, I18N_FILE).and_then(|strings| strings.validated().ok()).unwrap_or_default();
tauri_app.manage(Strings(Mutex::new(strings)));
tauri_app.manage(Host { config, hooks });
if let Some(setup) = setup {
setup(&handle)?;
}
open_window(tauri_app, &config)
})
.invoke_handler(move |invoke| {
if commands::is_host(invoke.message.command()) {
host_handler(invoke)
} else {
handler(invoke)
}
})
.run(context)
.unwrap_or_else(|error| panic!("Failed to launch {}: {error}", config.title));
}
}
/// [`App::new`] + [`App::setup`] + [`App::run`] in one call.
pub fn run_app<H>(
config: AppConfig,
hooks: impl SiteHooks,
setup: impl FnOnce(&AppHandle) -> Result<(), Box<dyn std::error::Error>> + Send + 'static,
context: tauri::Context<Wry>,
handler: H,
) where
H: Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static,
{
App::new(config, hooks).setup(setup).run(context, handler)
}
/// The main window (frameless on Windows/Linux, overlay titlebar on macOS) with the shell
/// webview filling it, shown once its saved geometry is restored.
fn open_window(app: &mut tauri::App<Wry>, config: &AppConfig) -> Result<(), Box<dyn std::error::Error>> {
let (r, g, b) = config.background;
let builder = tauri::window::WindowBuilder::new(app, policy::MAIN_LABEL)
.title(config.title)
.inner_size(config.window_size.0, config.window_size.1)
// The titlebar needs room for the logo, a few tabs and the caption buttons.
.min_inner_size(config.min_window_size.0, config.min_window_size.1)
// The shell draws its own titlebar (Windows 11: DWM shadow and rounded corners).
.decorations(!config.custom_titlebar)
.shadow(true)
// No white flash before the shell paints.
.background_color(tauri::window::Color(r, g, b, 255))
.resizable(true)
// Shown after the saved geometry is restored.
.visible(false);
// macOS keeps the native frame with an overlay titlebar and traffic lights.
// (`traffic_light_position` is not on the multiwebview WindowBuilder in tauri 2.12.)
#[cfg(target_os = "macos")]
let builder = if config.custom_titlebar {
builder.decorations(true).title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true)
} else {
builder
};
let window = builder.build()?;
{
use tauri_plugin_window_state::WindowExt;
// Never DECORATIONS: a state file from a framed build would re-enable the OS frame.
let _ = window.restore_state(window_state_flags());
}
reset_lost_geometry(&window, config.window_size, config.min_window_size);
// `taskkill` (and anything else closing "the first visible window") must close the app
// even when a minimized window sits below the runtime's helper windows.
host::route_helper_closes(&window);
// Tab webviews follow window resizes natively, before the shell lays out again.
host::follow_window_resizes(&window);
let size = window.inner_size()?.to_logical::<f64>(window.scale_factor()?);
// The shell fills the window; tab webviews are layered over its pane rectangles.
let shell = WebviewBuilder::new(policy::SHELL_LABEL, WebviewUrl::App("index.html".into()))
.auto_resize()
// HTML5 drag and drop (tab dragging, split drop zones) instead of OS file drops.
.disable_drag_drop_handler()
.on_navigation(|url| policy::local_navigation_allowed(url, cfg!(debug_assertions)))
.on_new_window(|_, _| NewWindowResponse::Deny);
// Raised over the tabs for an overlay (`shell_raise`), the page is see-through where the
// panes are; otherwise its own background paints as before. (macOS: `macos-private-api`.)
#[cfg(any(not(target_os = "macos"), feature = "macos-private-api"))]
let shell = shell.transparent(true);
let shell = window.add_child(shell, LogicalPosition::new(0.0, 0.0), LogicalSize::new(size.width, size.height))?;
host::attach_shell(&shell);
window.show()?;
Ok(())
}