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:
parent
ed06e24209
commit
d71a9391fd
7 changed files with 529 additions and 36 deletions
|
|
@ -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)?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue