host: plugins, custom CSS, feature flags, settings and open_panel are host commands

run_app now keeps and serves what every app's panel and page need, with the same caller checks
as the rest (commands::SHELL: shell_only first; testkit::assert_host_guards covers them):

- app.rs: PageState (the plugin store in app data `plugins`, the feature flags, ShellSettings)
  managed by the host; commands plugins_list, plugin_set_enabled, plugins_open_folder,
  custom_css_get/set, features_get/set, settings_get/set (moved from wayBetter for X). The app
  reads the state through app::page(), gets a loaded document's scripts from app::page_scripts()
  (flags, links, custom CSS, plugins) and writes through set_features/set_settings/set_custom_css
  (a backup import). AppConfig takes the app's PluginHost (AppConfig::new(site, title, plugins))
  and an optional home_url. SiteHooks::navigation_started and new_window_mode default to the
  owner's link settings.
- settings.rs (pure, tested): ShellSettings { homeUrl, externalLinks, siteLinks, restoreTabs }
  with its input form (missing fields take the defaults; `xLinks`, the name wayBetter for X used,
  is read as siteLinks), links_script ({ site: mode }), feature validation and features_script.
- commands::SITE + open_panel: a tab on the site's origin asks the shell to open the app's panel
  (site_tab_only first). testkit: assert_site_only(command, site) checks that every capability
  granting a site command is a tab capability limited to the site's URLs; assert_host_commands and
  assert_host_guards include the site commands.
- shell frame backupModel: a settings section that still says xLinks compares as siteLinks.
This commit is contained in:
CRTkafa 2026-09-30 14:28:52 +07:00
parent cecaf93639
commit 205ee82296
7 changed files with 529 additions and 36 deletions

View file

@ -1,15 +1,17 @@
//! 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.
//! 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, 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.
//! 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(CONFIG, MySite)
//! 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]);
//! }
@ -17,10 +19,13 @@
use crate::{
commands,
host::{self, emit_shell, emit_tab_state, read_json, shell_only, tab_only, tab_webview, write_json},
host::{self, emit_shell, emit_tab_state, read_json, shell_only, site_tab_only, tab_only, tab_webview, write_json},
i18n::{self, NativeStrings},
layout, policy,
layout,
plugins::{self, PluginHost, PluginStore, ScriptPlugin},
policy,
profiles::{self, AccountSeen, ProfileSettings},
settings::{self, ShellSettings, ShellSettingsInput},
site::SiteSpec,
tabs::{self, parse_web_url, OpenRequest, TabLayout, TabState},
};
@ -38,6 +43,12 @@ use url::Url;
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)]
@ -63,12 +74,18 @@ pub struct AppConfig {
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>,
}
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 {
/// 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,
@ -81,8 +98,15 @@ impl AppConfig {
deep_link: false,
updater: false,
page_string_prefixes: &["kit.", "plugin."],
plugins,
home_url: None,
}
}
/// 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`]).
@ -127,17 +151,18 @@ pub trait SiteHooks: Send + Sync + 'static {
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)
/// 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".
fn new_window_mode(&self, _app: &AppHandle, _url: &Url) -> String {
"tab".into()
/// "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`.
@ -170,8 +195,19 @@ pub struct ProfileState {
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,
}
struct Profiles(Mutex<ProfileState>);
struct Strings(Mutex<NativeStrings>);
struct Page(Mutex<PageState>);
struct Host {
config: AppConfig,
@ -197,6 +233,93 @@ pub fn profiles(app: &AppHandle) -> Result<MutexGuard<'_, ProfileState>, String>
.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> {
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()
@ -528,6 +651,82 @@ async fn i18n_set(webview: Webview, app: AppHandle, strings: NativeStrings) -> R
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)
}
#[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> {
@ -544,6 +743,21 @@ async fn tab_link_drag(webview: Webview, app: AppHandle, phase: String, url: Opt
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
@ -715,7 +929,7 @@ pub fn reset_lost_geometry(window: &tauri::Window, size: (f64, f64), min: (f64,
}
}
/// The host's commands ([`commands::SHELL`], [`commands::TAB`]).
/// 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,
@ -731,8 +945,18 @@ fn host_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 's
profiles_set,
i18n_get,
i18n_set,
plugins_list,
plugin_set_enabled,
plugins_open_folder,
custom_css_get,
custom_css_set,
features_get,
features_set,
settings_get,
settings_set,
tab_shortcut,
tab_link_drag
tab_link_drag,
open_panel
]
}
@ -758,7 +982,8 @@ impl App {
}
/// 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`]).
/// `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,
@ -806,6 +1031,21 @@ impl App {
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 features = read_json::<serde_json::Value>(&handle, FEATURES_FILE)
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::json!({}));
tauri_app.manage(Page(Mutex::new(PageState { plugins, features, settings })));
tauri_app.manage(Host { config, hooks });
if let Some(setup) = setup {
setup(&handle)?;