1667 lines
76 KiB
Rust
1667 lines
76 KiB
Rust
//! 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, the language, the plugins
|
|
//! folder and custom CSS, the page's feature flags and the shell's settings ([`PageState`]).
|
|
//!
|
|
//! The app hands it an [`AppConfig`] (its site, window title and size, its plugin host, which
|
|
//! Tauri 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(AppConfig::new(&SITE, "My app", &PLUGINS), 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, site_tab_only, tab_only, tab_webview, write_json},
|
|
i18n::{self, NativeStrings},
|
|
layout,
|
|
modes::{ModeState, MODES_FILE},
|
|
plugins::{self, PluginHost, PluginStore, ScriptPlugin},
|
|
policy,
|
|
profiles::{self, AccountSeen, ProfileSettings},
|
|
settings::{self, ShellSettings, ShellSettingsInput},
|
|
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";
|
|
/// The shell's settings (app data), see [`settings::ShellSettings`].
|
|
pub const SETTINGS_FILE: &str = "settings.json";
|
|
/// The page's feature flags (app data): a small map of booleans.
|
|
pub const FEATURES_FILE: &str = "features.json";
|
|
/// The plugins folder in app data (`*.plugin.js` files, `custom.css`, the enabled list).
|
|
pub const PLUGINS_DIR: &str = "plugins";
|
|
|
|
/// 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),
|
|
/// Wait for the shell's `shell_ready` command before showing the first window.
|
|
pub defer_window_show: bool,
|
|
/// 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],
|
|
/// The app's plugin host: the page runtime's global, the site's origin, the custom CSS element
|
|
/// and the plugins shipped with the app. The plugin store lives in app data [`PLUGINS_DIR`].
|
|
pub plugins: &'static PluginHost,
|
|
/// The home page before the owner sets one (default: the site's first origin).
|
|
pub home_url: Option<&'static str>,
|
|
/// The store this app installs themes and plugins from (`waybetter://install` links, dropped
|
|
/// files, the signed catalog): serves [`commands::STORE`]. Needs `deep_link` for the links and
|
|
/// the `net` feature for downloads.
|
|
pub store: Option<&'static crate::store::StoreConfig>,
|
|
/// The shell's media panel (gallery folders, the clipboard history): serves [`commands::MEDIA`].
|
|
pub media: bool,
|
|
/// Google Fonts (install, remove, preview; the cache in app data): serves [`commands::GFONTS`]
|
|
/// and calls [`SiteHooks::fonts_changed`]. Needs the `net` feature.
|
|
pub fonts: bool,
|
|
/// Encrypted folder sync of the settings backup: serves [`commands::SYNC`] and needs
|
|
/// [`SiteHooks::backup_sections`]. ([`commands::WORKSPACES`] is served for every app.)
|
|
pub sync: bool,
|
|
/// Developer mode (live reload of plugins, custom CSS and a theme file): serves
|
|
/// [`commands::DEV`] and runs the watcher while it is on; the theme file is the app's
|
|
/// ([`SiteHooks::dev_theme_apply`]).
|
|
pub dev: bool,
|
|
/// Site themes (the owner's theme, account and pane themes, the live preview): serves
|
|
/// [`commands::THEME`]; the app names its built-in themes and the files they are saved in.
|
|
pub themes: Option<&'static crate::theme_commands::ThemeConfig>,
|
|
/// Keyword alert settings (saved in app data, handed to the page runtime): serves
|
|
/// [`commands::ALERTS`]; the match and the notification are the app's.
|
|
pub alerts: Option<&'static crate::alerts_commands::AlertsConfig>,
|
|
/// The drafts vault (a SQLite database in app data with CAS revisions): serves
|
|
/// [`commands::VAULT`]; the rules (kinds, limits, capture ownership) are the app's.
|
|
#[cfg(feature = "vault")]
|
|
pub vault: Option<&'static crate::vault_commands::VaultConfig>,
|
|
/// Native updates and the hotfix channel: serves [`commands::UPDATE`] and runs the update loop.
|
|
/// Needs `updater` (the plugin) and the `net` feature.
|
|
#[cfg(feature = "net")]
|
|
pub updates: Option<&'static crate::updater::UpdaterConfig>,
|
|
}
|
|
|
|
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, the
|
|
/// site's first origin as the home page.
|
|
pub const fn new(site: &'static SiteSpec, title: &'static str, plugins: &'static PluginHost) -> Self {
|
|
Self {
|
|
site,
|
|
title,
|
|
legacy_identifier: None,
|
|
window_size: (1360.0, 900.0),
|
|
min_window_size: (900.0, 560.0),
|
|
background: (7, 8, 12),
|
|
defer_window_show: false,
|
|
custom_titlebar: true,
|
|
deep_link: false,
|
|
updater: false,
|
|
page_string_prefixes: &["kit.", "plugin."],
|
|
plugins,
|
|
home_url: None,
|
|
store: None,
|
|
media: false,
|
|
fonts: false,
|
|
sync: false,
|
|
dev: false,
|
|
themes: None,
|
|
alerts: None,
|
|
#[cfg(feature = "vault")]
|
|
vault: None,
|
|
#[cfg(feature = "net")]
|
|
updates: None,
|
|
}
|
|
}
|
|
|
|
/// Serves the update commands ([`commands::UPDATE`]) and runs the update loop.
|
|
#[cfg(feature = "net")]
|
|
pub const fn with_updates(mut self, updates: &'static crate::updater::UpdaterConfig) -> Self {
|
|
self.updates = Some(updates);
|
|
self
|
|
}
|
|
|
|
/// Serves the developer-mode commands ([`commands::DEV`]).
|
|
pub const fn with_dev(mut self) -> Self {
|
|
self.dev = true;
|
|
self
|
|
}
|
|
|
|
/// Serves the theme commands ([`commands::THEME`]) with these built-in themes.
|
|
pub const fn with_themes(mut self, themes: &'static crate::theme_commands::ThemeConfig) -> Self {
|
|
self.themes = Some(themes);
|
|
self
|
|
}
|
|
|
|
/// Serves the alert settings commands ([`commands::ALERTS`]).
|
|
pub const fn with_alerts(mut self, alerts: &'static crate::alerts_commands::AlertsConfig) -> Self {
|
|
self.alerts = Some(alerts);
|
|
self
|
|
}
|
|
|
|
/// Serves the drafts vault commands ([`commands::VAULT`]) over this database and rules.
|
|
#[cfg(feature = "vault")]
|
|
pub const fn with_vault(mut self, vault: &'static crate::vault_commands::VaultConfig) -> Self {
|
|
self.vault = Some(vault);
|
|
self
|
|
}
|
|
|
|
/// Serves the sync commands ([`commands::SYNC`]).
|
|
pub const fn with_sync(mut self) -> Self {
|
|
self.sync = true;
|
|
self
|
|
}
|
|
|
|
/// Serves the Google Fonts commands ([`commands::GFONTS`]).
|
|
pub const fn with_fonts(mut self) -> Self {
|
|
self.fonts = true;
|
|
self
|
|
}
|
|
|
|
/// Serves the media panel's commands ([`commands::MEDIA`]).
|
|
pub const fn with_media(mut self) -> Self {
|
|
self.media = true;
|
|
self
|
|
}
|
|
|
|
/// Serves the store's commands ([`commands::STORE`]) with this configuration.
|
|
pub const fn with_store(mut self, store: &'static crate::store::StoreConfig) -> Self {
|
|
self.store = Some(store);
|
|
self
|
|
}
|
|
|
|
/// The home page before the owner sets one.
|
|
pub fn home(&self) -> String {
|
|
self.home_url.map(str::to_owned).unwrap_or_else(|| format!("{}/", self.site.primary_origin()))
|
|
}
|
|
}
|
|
|
|
/// 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 document-start scripts of a tab in safe mode ([`crate::safe`]) instead of
|
|
/// [`Self::init_scripts`]: by default the host's tab bridge alone, so nothing of the app's page
|
|
/// layers runs. An app returns more here only for a layer it wants to survive safe mode.
|
|
fn safe_init_scripts(&self, _app: &AppHandle, _tab: &NewTab) -> Vec<String> {
|
|
vec![crate::bridge::SOURCE.to_owned()]
|
|
}
|
|
|
|
/// 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, [`ShellSettings::external_in_browser`]), or None when the
|
|
/// state is unavailable (the navigation is refused).
|
|
fn navigation_started(&self, app: &AppHandle, _label: &str) -> Option<bool> {
|
|
page(app).ok().map(|page| page.settings.external_in_browser())
|
|
}
|
|
|
|
/// Where a new-window request (target=_blank, window.open) for `url` opens: "tab", "pane" or
|
|
/// "browser" (the owner's link settings; a page on the site's origin counts as the site's).
|
|
fn new_window_mode(&self, app: &AppHandle, url: &Url) -> String {
|
|
let on_site = config(app).site.is_origin(url) || self.site_navigation(url);
|
|
page(app).map(|page| page.settings.new_window_mode(on_site).to_owned()).unwrap_or_else(|_| "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) {}
|
|
|
|
/// A Google font was installed or removed ([`commands::GFONTS`]); the host has already given the
|
|
/// site tabs their theme, and the font bytes, again ([`crate::theme_commands::apply`]).
|
|
fn fonts_changed(&self, _app: &AppHandle) {}
|
|
|
|
/// The app's native settings as backup sections (themes, plugins, tweaks, ... keyed by section
|
|
/// name, from app data): what a backup and [`commands::SYNC`] carry besides the shell's own
|
|
/// storage. By default none.
|
|
fn backup_sections(&self, _app: &AppHandle) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
|
Ok(serde_json::Map::new())
|
|
}
|
|
|
|
/// The names of the sections an import may carry (others are dropped). By default none.
|
|
fn backup_section_names(&self) -> &'static [&'static str] {
|
|
&[]
|
|
}
|
|
|
|
/// The `drafts` backup section (only when the owner ticks "include drafts"), or none. By default none.
|
|
fn backup_drafts(&self, _app: &AppHandle) -> Result<Option<serde_json::Value>, String> {
|
|
Ok(None)
|
|
}
|
|
|
|
/// Applies the sections of an import the owner chose ([`commands::BACKUP`]): check each the way
|
|
/// its own setter does, write nothing if any is bad, and return what was applied and notes for
|
|
/// the owner (`{ applied: [..], notes: [..] }`). By default importing is not supported.
|
|
fn backup_apply(&self, _app: &AppHandle, _sections: &serde_json::Map<String, serde_json::Value>) -> Result<serde_json::Value, String> {
|
|
Err("UNAVAILABLE: this app cannot import a backup".into())
|
|
}
|
|
|
|
/// Developer mode: show the theme file the owner picked (read `path`, make it the theme preview
|
|
/// in every site tab). An error is shown with its file and line. By default themes are not
|
|
/// supported and this does nothing.
|
|
fn dev_theme_apply(&self, _app: &AppHandle, _path: &std::path::Path) -> Result<(), crate::devmode::DevError> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Developer mode ended (or the theme file was cleared): end the preview [`Self::dev_theme_apply`] made.
|
|
fn dev_theme_end(&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>,
|
|
}
|
|
|
|
/// What every site page gets and the shell's settings: the plugin store (plugins, custom CSS),
|
|
/// the feature flags and [`ShellSettings`]. The host's commands read and save them; the app reads
|
|
/// them through [`page`] (its page scripts, backups, developer mode).
|
|
pub struct PageState {
|
|
pub plugins: PluginStore,
|
|
/// A JSON object of booleans ([`settings::validate_features`]).
|
|
pub features: serde_json::Value,
|
|
pub settings: ShellSettings,
|
|
/// The onboarding mode the owner started from and whether the first-run screen was answered.
|
|
pub modes: ModeState,
|
|
}
|
|
|
|
struct Profiles(Mutex<ProfileState>);
|
|
struct Strings(Mutex<NativeStrings>);
|
|
struct Page(Mutex<PageState>);
|
|
|
|
struct Host {
|
|
config: AppConfig,
|
|
hooks: Arc<dyn SiteHooks>,
|
|
}
|
|
|
|
fn host_state(app: &AppHandle) -> tauri::State<'_, Host> {
|
|
app.state::<Host>()
|
|
}
|
|
|
|
/// The app's site hooks ([`run_app`] must be running).
|
|
pub(crate) fn hooks(app: &AppHandle) -> Arc<dyn SiteHooks> {
|
|
host_state(app).hooks.clone()
|
|
}
|
|
|
|
/// The app's config ([`run_app`] must be running).
|
|
/// The document-start script that gives a new site tab the current string packs (none when the
|
|
/// shell never applied any, or the app has no store). Part of the normal page layers, so safe mode
|
|
/// leaves it out.
|
|
fn strings_layer(app: &AppHandle) -> Option<String> {
|
|
let stored = app.try_state::<crate::store::Store>()?.strings().delivery()?;
|
|
Some(crate::store::delivery_script(config(app).site.hosts, &stored, false))
|
|
}
|
|
|
|
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 page state lock (plugins, feature flags, settings). Never call an app hook while holding
|
|
/// it; an app that holds a lock of its own may take this one after it, never the other way round.
|
|
pub fn page(app: &AppHandle) -> Result<MutexGuard<'_, PageState>, String> {
|
|
app.try_state::<Page>()
|
|
.ok_or("DATABASE: workspace state unavailable; restart safely")?
|
|
.inner()
|
|
.0
|
|
.lock()
|
|
.map_err(|_| "DATABASE: workspace state unavailable; restart safely".into())
|
|
}
|
|
|
|
/// The scripts a freshly loaded site document gets from the page state, in the order they apply.
|
|
pub struct PageScripts {
|
|
/// The feature flags ([`settings::features_script`]).
|
|
pub features: String,
|
|
/// Where clicked site pages open ([`settings::links_script`]).
|
|
pub links: String,
|
|
/// The owner's custom CSS, when there is any.
|
|
pub css: Option<String>,
|
|
/// One start script per enabled plugin.
|
|
pub plugins: Vec<String>,
|
|
}
|
|
|
|
impl PageScripts {
|
|
/// Flags, links, custom CSS, then the plugins.
|
|
pub fn all(self) -> Vec<String> {
|
|
let mut scripts = vec![self.features, self.links];
|
|
scripts.extend(self.css);
|
|
scripts.extend(self.plugins);
|
|
scripts
|
|
}
|
|
}
|
|
|
|
/// The page state as scripts for a site document that just loaded (evaluate them natively, so the
|
|
/// page's CSP cannot block them). An app with scripts of its own places them between these.
|
|
pub fn page_scripts(app: &AppHandle) -> Result<PageScripts, String> {
|
|
// Safe mode: a site page gets none of them.
|
|
if crate::safe::active() {
|
|
return Ok(PageScripts { features: String::new(), links: String::new(), css: None, plugins: Vec::new() });
|
|
}
|
|
let site = config(app).site;
|
|
let page = page(app)?;
|
|
let css = page.plugins.css();
|
|
Ok(PageScripts {
|
|
features: settings::features_script(site, &page.features),
|
|
links: settings::links_script(site, &page.settings),
|
|
css: if css.is_empty() { None } else { plugins::css_script(page.plugins.host(), &css).ok() },
|
|
plugins: page.plugins.start_scripts(),
|
|
})
|
|
}
|
|
|
|
/// Evaluates `script` in every tab that is on the site right now.
|
|
pub fn eval_in_site_tabs(app: &AppHandle, script: &str) {
|
|
host::eval_in_site_tabs(app, config(app).site, script);
|
|
}
|
|
|
|
/// Replaces the feature flags (validated booleans), saves them and applies them in every site tab.
|
|
/// For the app's own writers (a backup import); the shell uses `features_set`.
|
|
pub fn set_features(app: &AppHandle, features: serde_json::Map<String, serde_json::Value>) -> Result<(), String> {
|
|
settings::validate_features(&features)?;
|
|
let value = serde_json::Value::Object(features);
|
|
write_json(app, FEATURES_FILE, &value)?;
|
|
let script = settings::features_script(config(app).site, &value);
|
|
page(app)?.features = value;
|
|
eval_in_site_tabs(app, &script);
|
|
Ok(())
|
|
}
|
|
|
|
/// Replaces the shell's settings (validated), saves them and tells every site tab where its links open.
|
|
/// For the app's own writers (a backup import); the shell uses `settings_set`.
|
|
pub fn set_settings(app: &AppHandle, input: ShellSettingsInput) -> Result<ShellSettings, String> {
|
|
let config = config(app);
|
|
let next = ShellSettings::from_input(input, &config.home())?;
|
|
write_json(app, SETTINGS_FILE, &next)?;
|
|
page(app)?.settings = next.clone();
|
|
eval_in_site_tabs(app, &settings::links_script(config.site, &next));
|
|
Ok(next)
|
|
}
|
|
|
|
/// Replaces the custom CSS, saves it and applies it in every site tab.
|
|
pub fn set_custom_css(app: &AppHandle, css: &str) -> Result<(), String> {
|
|
let script = {
|
|
let page = page(app)?;
|
|
let script = plugins::css_script(page.plugins.host(), css)?;
|
|
page.plugins.set_css(css)?;
|
|
script
|
|
};
|
|
eval_in_site_tabs(app, &script);
|
|
Ok(())
|
|
}
|
|
|
|
/// 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]
|
|
pub(crate) async fn tabs_sync(webview: Webview, app: AppHandle, items: Vec<TabLayout>, viewport: Option<[f64; 2]>) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
#[cfg(debug_assertions)]
|
|
crate::devloop::remember_layout(&items, viewport);
|
|
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()) => {
|
|
host::forget_tab(&app, &label);
|
|
let _ = tab.close();
|
|
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 crate::safe::layer(crate::safe::active(), || hooks.tab_background(&app, &label), || None) {
|
|
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);
|
|
}
|
|
// A new webview starts at 100%: this tab's remembered page zoom.
|
|
host::restore_tab_zoom(&app, &tab);
|
|
tab
|
|
}
|
|
Err(error) => {
|
|
profiles(&app)?.tab_profiles.remove(&label);
|
|
return Err(format!("TAB: {error}"));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if !crate::safe::active() {
|
|
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();
|
|
host::forget_tab(&app, &label);
|
|
let _ = existing.close();
|
|
if let Ok(mut data) = profiles(&app) {
|
|
data.tab_profiles.remove(&label);
|
|
}
|
|
host::forget_tab_zoom(&app, id);
|
|
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)
|
|
}
|
|
|
|
/// The page zoom of tab `id` (the app menu's zoom row, Ctrl with + / - / 0): sets it to `factor`
|
|
/// (clamped to 25%..500%, remembered per tab in app data `zoom.json`), or only reads it without
|
|
/// one. Returns the factor in effect. The engine's own page zoom; nothing is injected into the page.
|
|
#[tauri::command]
|
|
async fn tab_zoom(webview: Webview, app: AppHandle, id: String, factor: Option<f64>) -> Result<f64, String> {
|
|
shell_only(&webview)?;
|
|
host::tab_zoom(&app, &id, factor)
|
|
}
|
|
|
|
/// Find in page for tab `id` (the shell's find bar): `start` with a query, `next`, `previous`, `stop`.
|
|
/// The engine's own find where this build can drive it (counts arrive as `tab-find` events), else the
|
|
/// evaluated Custom Highlight script. The query is one typed line (validated) and is kept nowhere.
|
|
#[tauri::command]
|
|
async fn tab_find(webview: Webview, app: AppHandle, id: String, action: String, query: Option<String>) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
host::tab_find(&app, &id, &action, query.as_deref())
|
|
}
|
|
|
|
/// Sets the native menu bar (macOS) from the shell's description ([`crate::menu`]); other platforms keep the
|
|
/// in-app menu and answer false. Choosing an item sends its id to the shell (`native-menu`).
|
|
#[tauri::command]
|
|
async fn menu_set(webview: Webview, app: AppHandle, spec: serde_json::Value) -> Result<bool, String> {
|
|
shell_only(&webview)?;
|
|
let spec = crate::menu::parse(spec)?;
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
crate::menu::set(&app, &spec)?;
|
|
Ok(true)
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
let _ = (app, spec);
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
/// Whether safe mode is on ([`crate::safe`]): the shell shows it in the titlebar and drops its chrome theme.
|
|
#[tauri::command]
|
|
async fn safe_mode_get(webview: Webview) -> Result<crate::safe::SafeState, String> {
|
|
shell_only(&webview)?;
|
|
Ok(crate::safe::state())
|
|
}
|
|
|
|
/// Switches safe mode for this run (memory only, never saved) and rebuilds every tab webview, so
|
|
/// each page starts again with or without the app's page layers. The app menu's "Restart in safe
|
|
/// mode" and the titlebar's "Leave".
|
|
#[tauri::command]
|
|
async fn safe_mode_set(webview: Webview, app: AppHandle, on: bool) -> Result<crate::safe::SafeState, String> {
|
|
shell_only(&webview)?;
|
|
if crate::safe::set(on) {
|
|
emit_shell(&app, crate::safe::EVENT, crate::safe::state());
|
|
rebuild_tabs(&app);
|
|
}
|
|
Ok(crate::safe::state())
|
|
}
|
|
|
|
/// Every tab webview is built again on the shell's next layout: their recorded profiles are marked
|
|
/// stale (`tabs_sync` then closes and recreates each one with the document-start scripts of the
|
|
/// moment) and the shell is asked to send its layout.
|
|
pub fn rebuild_tabs(app: &AppHandle) {
|
|
if let Ok(mut data) = profiles(app) {
|
|
for profile in data.tab_profiles.values_mut() {
|
|
*profile = crate::safe::STALE_PROFILE.to_owned();
|
|
}
|
|
}
|
|
emit_shell(app, crate::safe::EVENT_REBUILD, ());
|
|
}
|
|
|
|
/// 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)?;
|
|
let result = webview.set_focus().map_err(|error| format!("FOCUS: {error}"));
|
|
crate::diag::log("host", &format!("shell_focus -> {}", if result.is_ok() { "ok" } else { "error" }));
|
|
result
|
|
}
|
|
|
|
/// The shell calls this after its first DOM commit, so startup never exposes an empty webview.
|
|
#[tauri::command]
|
|
async fn shell_ready(webview: Webview, app: AppHandle) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
app.get_window(policy::MAIN_LABEL)
|
|
.ok_or("WINDOW: main window missing")?
|
|
.show()
|
|
.map_err(|error| format!("WINDOW: {error}"))
|
|
}
|
|
|
|
/// The shell has committed a captured tab frame; its native webview can now be hidden.
|
|
#[tauri::command]
|
|
async fn tab_still_ready(webview: Webview, app: AppHandle, id: String, token: u64) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
if !policy::valid_tab_id(&id) { return Err("VALIDATION: invalid tab id".into()); }
|
|
host::acknowledge_still(&app, &id, token);
|
|
Ok(())
|
|
}
|
|
|
|
/// 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);
|
|
let supported = host::raise_shell(&webview, on);
|
|
crate::diag::log("host", &format!("shell_raise on={on} -> supported={supported}"));
|
|
Ok(supported)
|
|
}
|
|
|
|
/// Whether focus and layering diagnostics are on for this run ([`crate::diag`]).
|
|
#[tauri::command]
|
|
async fn diag_get(webview: Webview) -> Result<bool, String> {
|
|
shell_only(&webview)?;
|
|
Ok(crate::diag::active())
|
|
}
|
|
|
|
/// One diagnostic line from the shell (plain short text; nothing is written unless diagnostics are on).
|
|
#[tauri::command]
|
|
async fn diag_log(webview: Webview, line: String) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
crate::diag::log("shell", &line);
|
|
Ok(())
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn plugins_list(webview: Webview, app: AppHandle) -> Result<Vec<ScriptPlugin>, String> {
|
|
shell_only(&webview)?;
|
|
page(&app)?.plugins.list()
|
|
}
|
|
|
|
/// Turns a plugin on or off and starts or stops it live in every site tab.
|
|
#[tauri::command]
|
|
async fn plugin_set_enabled(webview: Webview, app: AppHandle, file: String, enabled: bool) -> Result<Vec<ScriptPlugin>, String> {
|
|
shell_only(&webview)?;
|
|
let (script, list) = {
|
|
let page = page(&app)?;
|
|
// Build the start script first so a broken file is reported before it is marked enabled.
|
|
let script = if enabled { page.plugins.start_script(&file)? } else { plugins::stop_script(page.plugins.host(), &file)? };
|
|
page.plugins.set_enabled(&file, enabled)?;
|
|
(script, page.plugins.list()?)
|
|
};
|
|
eval_in_site_tabs(&app, &script);
|
|
Ok(list)
|
|
}
|
|
|
|
/// Opens the plugins folder in the system's file manager.
|
|
#[tauri::command]
|
|
async fn plugins_open_folder(webview: Webview, app: AppHandle) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
let dir = {
|
|
let page = page(&app)?;
|
|
page.plugins.ensure()?;
|
|
page.plugins.dir().to_path_buf()
|
|
};
|
|
#[cfg(target_os = "windows")]
|
|
let opener = "explorer";
|
|
#[cfg(target_os = "macos")]
|
|
let opener = "open";
|
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
let opener = "xdg-open";
|
|
std::process::Command::new(opener).arg(dir).spawn().map(|_| ()).map_err(|error| format!("PLUGINS: {error}"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn custom_css_get(webview: Webview, app: AppHandle) -> Result<String, String> {
|
|
shell_only(&webview)?;
|
|
Ok(page(&app)?.plugins.css())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn custom_css_set(webview: Webview, app: AppHandle, css: String) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
set_custom_css(&app, &css)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn features_get(webview: Webview, app: AppHandle) -> Result<serde_json::Value, String> {
|
|
shell_only(&webview)?;
|
|
Ok(page(&app)?.features.clone())
|
|
}
|
|
|
|
/// Feature flags are booleans only; the page normalizes them again before use.
|
|
#[tauri::command]
|
|
async fn features_set(webview: Webview, app: AppHandle, features: serde_json::Map<String, serde_json::Value>) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
set_features(&app, features)
|
|
}
|
|
|
|
/// Runs a plugin's palette command in tab `id`: only a command that tab reported, only while it is
|
|
/// on the site. The command's code runs in the page; the tab gets keyboard focus for what it opens.
|
|
#[tauri::command]
|
|
async fn tab_run_command(webview: Webview, app: AppHandle, id: String, command: String) -> Result<(), String> {
|
|
shell_only(&webview)?;
|
|
crate::tab_commands::run(&app, &id, &command)
|
|
}
|
|
|
|
/// The onboarding mode: what was chosen, whether the first-run screen was answered, and whether
|
|
/// this app was set up before modes existed.
|
|
#[tauri::command]
|
|
async fn mode_get(webview: Webview, app: AppHandle) -> Result<ModeState, String> {
|
|
shell_only(&webview)?;
|
|
Ok(page(&app)?.modes.clone())
|
|
}
|
|
|
|
/// Remembers the chosen mode (or none), that the first-run screen was answered, and what the
|
|
/// mode's preset was when it was applied (`preset`, flag names to on/off: data for the shell's
|
|
/// "custom" and "this mode has new features" notes). The preset's flags are the shell's to apply
|
|
/// (`features_set`); this only records the choice.
|
|
#[tauri::command]
|
|
async fn mode_set(webview: Webview, app: AppHandle, mode: Option<String>, onboarded: bool, preset: Option<std::collections::BTreeMap<String, bool>>) -> Result<ModeState, String> {
|
|
shell_only(&webview)?;
|
|
let next = page(&app)?.modes.chosen(mode, onboarded, preset)?;
|
|
write_json(&app, MODES_FILE, &next)?;
|
|
page(&app)?.modes = next.clone();
|
|
Ok(next)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn settings_get(webview: Webview, app: AppHandle) -> Result<ShellSettings, String> {
|
|
shell_only(&webview)?;
|
|
Ok(page(&app)?.settings.clone())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn settings_set(webview: Webview, app: AppHandle, settings: ShellSettingsInput) -> Result<ShellSettings, String> {
|
|
shell_only(&webview)?;
|
|
set_settings(&app, settings)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// The app's entry in the site's page (its navigation item, the floating button) opens the app's
|
|
/// panel in the shell, at a section. Only from a tab that is on the site right now.
|
|
#[tauri::command]
|
|
async fn open_panel(webview: Webview, app: AppHandle, section: Option<String>) -> Result<(), String> {
|
|
site_tab_only(config(&app).site, &webview)?;
|
|
let id = tab_only(&webview)?;
|
|
#[derive(Clone, Serialize)]
|
|
struct Open {
|
|
from: String,
|
|
section: Option<String>,
|
|
}
|
|
emit_shell(&app, tabs::EVENT_OPEN_PANEL, Open { from: id, section: section.filter(|s| s.len() <= 32) });
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// 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));
|
|
}
|
|
}
|
|
// Safe mode: the safe set (the tab bridge) instead of the app's page layers.
|
|
// The string packs' delivery goes first, before the page runtime reads it (not in safe mode).
|
|
let layers = || strings_layer(app).into_iter().chain(hooks.init_scripts(app, tab)).collect::<Vec<_>>();
|
|
for script in crate::safe::layer(crate::safe::active(), layers, || hooks.safe_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(¤t));
|
|
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 {
|
|
crate::tab_commands::cleared(app, tab.label());
|
|
load_hooks.page_started(app, &tab);
|
|
// An engine that resets page zoom on navigation still shows this tab's.
|
|
host::restore_tab_zoom(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()) && !crate::safe::active() {
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// A restored window lies inside the work area of the monitor it opens on (the primary one when
|
|
/// that monitor is gone): a state saved on a larger or rearranged screen would otherwise leave
|
|
/// its titlebar out of reach ([`crate::window_fit::fit`]). A maximized or full-screen window is
|
|
/// the OS's to place.
|
|
pub fn fit_to_monitor(window: &tauri::Window) {
|
|
use crate::window_fit::{fit, Rect};
|
|
if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false) {
|
|
return;
|
|
}
|
|
let (Ok(position), Ok(size), Ok(monitors)) = (window.outer_position(), window.outer_size(), window.available_monitors()) else { return };
|
|
let area = |monitor: &tauri::Monitor| {
|
|
let work = monitor.work_area();
|
|
Rect::new(work.position.x, work.position.y, work.size.width, work.size.height)
|
|
};
|
|
let areas: Vec<Rect> = monitors.iter().map(area).collect();
|
|
let primary = window.primary_monitor().ok().flatten().map(|monitor| area(&monitor));
|
|
let Some(fitted) = fit(Rect::new(position.x, position.y, size.width, size.height), &areas, primary) else { return };
|
|
if (fitted.width, fitted.height) != (size.width, size.height) {
|
|
// The outer frame must fit; the inner size is what can be set, so the frame's border comes off it.
|
|
let inner = window.inner_size().unwrap_or(size);
|
|
let (extra_w, extra_h) = (size.width.saturating_sub(inner.width), size.height.saturating_sub(inner.height));
|
|
let _ = window.set_size(tauri::PhysicalSize::new(fitted.width.saturating_sub(extra_w), fitted.height.saturating_sub(extra_h)));
|
|
}
|
|
let _ = window.set_position(tauri::PhysicalPosition::new(fitted.x, fitted.y));
|
|
}
|
|
|
|
/// The host's commands ([`commands::SHELL`], [`commands::TAB`], [`commands::SITE`]).
|
|
fn host_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
tauri::generate_handler![
|
|
tabs_sync,
|
|
tab_navigate,
|
|
tab_action,
|
|
tab_zoom,
|
|
tab_find,
|
|
menu_set,
|
|
safe_mode_get,
|
|
safe_mode_set,
|
|
diag_get,
|
|
diag_log,
|
|
open_external_url,
|
|
shell_focus,
|
|
shell_ready,
|
|
tab_still_ready,
|
|
shell_raise,
|
|
window_system_menu,
|
|
profiles_get,
|
|
profiles_set,
|
|
i18n_get,
|
|
i18n_set,
|
|
plugins_list,
|
|
plugin_set_enabled,
|
|
plugins_open_folder,
|
|
custom_css_get,
|
|
custom_css_set,
|
|
features_get,
|
|
features_set,
|
|
mode_get,
|
|
mode_set,
|
|
settings_get,
|
|
settings_set,
|
|
tab_run_command,
|
|
tab_shortcut,
|
|
tab_link_drag,
|
|
open_panel
|
|
]
|
|
}
|
|
|
|
/// The store's commands ([`commands::STORE`]): with the `net` feature (downloads) only.
|
|
#[cfg(feature = "net")]
|
|
fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::store_commands::handler()
|
|
}
|
|
|
|
#[cfg(not(feature = "net"))]
|
|
fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
|_| false
|
|
}
|
|
|
|
/// The backup commands ([`commands::BACKUP`]).
|
|
fn backup_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::backup_commands::handler()
|
|
}
|
|
|
|
/// The developer-mode commands ([`commands::DEV`]).
|
|
fn dev_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::dev_commands::handler()
|
|
}
|
|
|
|
/// The alert settings commands ([`commands::ALERTS`]).
|
|
fn alerts_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::alerts_commands::handler()
|
|
}
|
|
|
|
/// The drafts vault commands ([`commands::VAULT`]); without the `vault` feature none are served.
|
|
fn vault_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
#[cfg(feature = "vault")]
|
|
return crate::vault_commands::handler();
|
|
#[cfg(not(feature = "vault"))]
|
|
|_: tauri::ipc::Invoke<Wry>| false
|
|
}
|
|
|
|
/// The theme commands ([`commands::THEME`]).
|
|
fn theme_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::theme_commands::handler()
|
|
}
|
|
|
|
/// The workspaces and sync commands ([`commands::WORKSPACES`], [`commands::SYNC`]).
|
|
fn sync_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::sync_commands::handler()
|
|
}
|
|
|
|
/// The update commands ([`commands::UPDATE`]): with the `net` feature only.
|
|
#[cfg(feature = "net")]
|
|
fn update_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::update_commands::handler()
|
|
}
|
|
|
|
#[cfg(not(feature = "net"))]
|
|
fn update_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
|_| false
|
|
}
|
|
|
|
/// The Google Fonts commands ([`commands::GFONTS`]): with the `net` feature (downloads) only.
|
|
#[cfg(feature = "net")]
|
|
fn fonts_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::fonts_commands::handler()
|
|
}
|
|
|
|
#[cfg(not(feature = "net"))]
|
|
fn fonts_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
|_| false
|
|
}
|
|
|
|
/// The media panel's commands ([`commands::MEDIA`]).
|
|
fn media_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
|
crate::media_commands::handler()
|
|
}
|
|
|
|
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`], [`commands::TAB`] and
|
|
/// [`commands::SITE`]).
|
|
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 store_handler = store_handler();
|
|
let media_handler = media_handler();
|
|
let fonts_handler = fonts_handler();
|
|
let update_handler = update_handler();
|
|
let sync_handler = sync_handler();
|
|
let dev_handler = dev_handler();
|
|
let theme_handler = theme_handler();
|
|
let alerts_handler = alerts_handler();
|
|
let vault_handler = vault_handler();
|
|
let backup_handler = backup_handler();
|
|
let builder = tauri::Builder::<Wry>::default()
|
|
// The native menu bar's choices go to the shell, which runs them ([`crate::menu`]).
|
|
.on_menu_event(|app, event| {
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(id) = crate::menu::chosen(event.id().as_ref()) {
|
|
emit_shell(app, crate::menu::EVENT_NATIVE_MENU, id.to_owned());
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
let _ = (app, event);
|
|
})
|
|
// 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, _args, _cwd| {
|
|
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
|
|
let _ = window.unminimize();
|
|
let _ = window.show();
|
|
let _ = window.set_focus();
|
|
}
|
|
// A double-click or "Open with" on a file while the app runs: its path arrives here.
|
|
#[cfg(feature = "net")]
|
|
crate::store_commands::opened_args(app, &_args, &_cwd);
|
|
}));
|
|
// 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())
|
|
// No injected link handler: in a site tab it cancels every target=_blank click and asks for
|
|
// `plugin:opener|open_url`, which tabs may not call, so nothing opened. Native code routes
|
|
// new-window requests itself (`on_new_window`); the shell opens links through its commands.
|
|
.plugin(tauri_plugin_opener::Builder::new().open_js_links_on_click(false).build());
|
|
// 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)?;
|
|
// Someone set this app up before (read before anything below writes a file).
|
|
let existing = [PROFILES_FILE, SETTINGS_FILE, FEATURES_FILE, PLUGINS_DIR].iter().any(|name| directory.join(name).exists());
|
|
#[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)));
|
|
// The plugins folder (bundled plugins are upgraded at startup, not only when the
|
|
// Plugins list is opened: enabled ones would otherwise keep running the previous
|
|
// version's code), the feature flags and the shell's settings.
|
|
let plugins = PluginStore::new(directory.join(PLUGINS_DIR), config.plugins);
|
|
if let Err(error) = plugins.ensure() {
|
|
eprintln!("{}: {error}", config.title);
|
|
}
|
|
let home = config.home();
|
|
let settings = read_json::<serde_json::Value>(&handle, SETTINGS_FILE)
|
|
.and_then(|value| ShellSettings::from_value(value, &home).ok())
|
|
.unwrap_or_else(|| ShellSettings::defaults(&home));
|
|
let mut features = read_json::<serde_json::Value>(&handle, FEATURES_FILE)
|
|
.filter(serde_json::Value::is_object)
|
|
.unwrap_or_else(|| serde_json::json!({}));
|
|
// Plugin files the app turned into features: what was enabled becomes its flag, once
|
|
// (plugins::migrate). The flags are saved before any file is touched.
|
|
let migrated = plugins.migrate_superseded(config.plugins.superseded, |flags| {
|
|
let Some(map) = features.as_object_mut().filter(|_| !flags.is_empty()) else { return Ok(()) };
|
|
for flag in flags {
|
|
map.insert(flag.clone(), serde_json::Value::Bool(true));
|
|
}
|
|
settings::validate_features(map)?;
|
|
write_json(&handle, FEATURES_FILE, &*map)
|
|
});
|
|
if let Err(error) = migrated {
|
|
eprintln!("{}: {error}", config.title);
|
|
}
|
|
let modes = read_json::<ModeState>(&handle, MODES_FILE).and_then(|state| state.validated().ok()).unwrap_or_else(|| {
|
|
let state = ModeState::first_run(existing);
|
|
let _ = write_json(&handle, MODES_FILE, &state);
|
|
state
|
|
});
|
|
tauri_app.manage(Page(Mutex::new(PageState { plugins, features, settings, modes })));
|
|
tauri_app.manage(Host { config, hooks });
|
|
#[cfg(feature = "net")]
|
|
crate::store_commands::init(&handle, &config);
|
|
crate::media_commands::init(&handle);
|
|
crate::sync_commands::init(&handle);
|
|
crate::dev_commands::init(&handle);
|
|
crate::theme_commands::init(&handle);
|
|
crate::alerts_commands::init(&handle);
|
|
#[cfg(feature = "vault")]
|
|
crate::vault_commands::init(&handle)?;
|
|
#[cfg(feature = "net")]
|
|
crate::fonts_commands::init(&handle);
|
|
#[cfg(feature = "net")]
|
|
crate::update_commands::init(&handle);
|
|
// Safe mode for this run: the launch argument, or Shift held while starting (Windows).
|
|
crate::safe::set(crate::safe::at_launch(std::env::args(), host::shift_held()));
|
|
// Focus and layering diagnostics for this run: `--diag` or WAYBETTER_DIAG=1 (app log folder, diag.log).
|
|
if crate::diag::asked(std::env::args(), std::env::var(crate::diag::ENV).ok().as_deref()) {
|
|
crate::diag::start(handle.path().app_log_dir().ok());
|
|
}
|
|
#[cfg(debug_assertions)]
|
|
crate::devloop::start(&handle);
|
|
if let Some(setup) = setup {
|
|
setup(&handle)?;
|
|
}
|
|
open_window(tauri_app, &config)
|
|
})
|
|
.invoke_handler(move |invoke| {
|
|
if commands::STORE.contains(&invoke.message.command()) {
|
|
store_handler(invoke)
|
|
} else if commands::MEDIA.contains(&invoke.message.command()) {
|
|
media_handler(invoke)
|
|
} else if commands::GFONTS.contains(&invoke.message.command()) {
|
|
fonts_handler(invoke)
|
|
} else if commands::UPDATE.contains(&invoke.message.command()) {
|
|
update_handler(invoke)
|
|
} else if commands::WORKSPACES.contains(&invoke.message.command()) || commands::SYNC.contains(&invoke.message.command()) {
|
|
sync_handler(invoke)
|
|
} else if commands::DEV.contains(&invoke.message.command()) {
|
|
dev_handler(invoke)
|
|
} else if commands::BACKUP.contains(&invoke.message.command()) {
|
|
backup_handler(invoke)
|
|
} else if commands::THEME.contains(&invoke.message.command()) {
|
|
theme_handler(invoke)
|
|
} else if commands::ALERTS.contains(&invoke.message.command()) {
|
|
alerts_handler(invoke)
|
|
} else if commands::VAULT.contains(&invoke.message.command()) {
|
|
vault_handler(invoke)
|
|
} else if commands::is_host(invoke.message.command()) {
|
|
host_handler(invoke)
|
|
} else {
|
|
handler(invoke)
|
|
}
|
|
})
|
|
.build(context)
|
|
.unwrap_or_else(|error| panic!("Failed to launch {}: {error}", config.title))
|
|
.run(|app, event| on_run_event(app, &event));
|
|
}
|
|
}
|
|
|
|
/// Run-loop events: the macOS close-hides rule, and why the app is going away for `--diag` ([`crate::diag`]): an exit request with its code (None is a
|
|
/// polite quit from outside: Cmd+Q, the Dock, or another app such as an auto-quitter closing the last
|
|
/// window), window close/destroy events and the final exit. Nothing is written without diagnostics.
|
|
fn on_run_event(app: &AppHandle, event: &tauri::RunEvent) {
|
|
// macOS: the red close button sends the main window to the Dock (minimized, not hidden: an auto-quitter
|
|
// such as Swift Quit terminates an app whose window list goes empty, but not one with a minimized window)
|
|
// and the app keeps running; a Dock click shows it
|
|
// again; Cmd+Q still quits (an exit request, not a window close).
|
|
#[cfg(target_os = "macos")]
|
|
match event {
|
|
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { api, .. }, .. } if label == policy::MAIN_LABEL => {
|
|
api.prevent_close();
|
|
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
|
|
let _ = window.minimize();
|
|
}
|
|
}
|
|
tauri::RunEvent::Reopen { .. } => {
|
|
if let Some(window) = app.get_window(policy::MAIN_LABEL) {
|
|
let _ = window.unminimize();
|
|
let _ = window.show();
|
|
let _ = window.set_focus();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
let _ = app;
|
|
match event {
|
|
tauri::RunEvent::ExitRequested { code, .. } => crate::diag::log("host", &format!("exit requested code={code:?}")),
|
|
tauri::RunEvent::Exit => crate::diag::log("host", "exit"),
|
|
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { .. }, .. } => crate::diag::log("host", &format!("window {label} close requested")),
|
|
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::Destroyed, .. } => crate::diag::log("host", &format!("window {label} destroyed")),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// [`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);
|
|
fit_to_monitor(&window);
|
|
// `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);
|
|
if config.defer_window_show {
|
|
// A shell that opts in shows the window after initial layout and sync. If JS fails to
|
|
// start, make the failure visible rather than leaving a hidden process.
|
|
let fallback = window.clone();
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
|
if fallback.is_visible().is_ok_and(|visible| !visible) {
|
|
let _ = fallback.show();
|
|
}
|
|
});
|
|
} else {
|
|
window.show()?;
|
|
}
|
|
Ok(())
|
|
}
|