host: site themes are the host's (AppConfig::with_themes, commands::THEME)

theme_commands serves theme_get / theme_set / theme_preview / theme_scopes_get / theme_scopes_set
(shell-only, each checks its caller first; testkit::assert_theme_guards) over the theme state the
host now keeps: the owner's theme, account and pane themes, the Theme Builder preview. The app
only names its built-in themes and the two files (ThemeConfig). apply() themes every site tab
(script, cached Google font bytes, native background); page_scripts()/tab_background() serve a new
document; set_theme/set_scopes/set_preview are the app's own writers (backup import, dev mode).
A font install or removal re-themes the tabs in the host itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 14:10:43 +07:00
parent 260646bbc0
commit 82382293a2
6 changed files with 276 additions and 7 deletions

View file

@ -96,6 +96,9 @@ pub struct AppConfig {
/// [`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>,
/// 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")]
@ -126,6 +129,7 @@ impl AppConfig {
fonts: false,
sync: false,
dev: false,
themes: None,
#[cfg(feature = "net")]
updates: None,
}
@ -144,6 +148,12 @@ impl AppConfig {
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 sync commands ([`commands::SYNC`]).
pub const fn with_sync(mut self) -> Self {
self.sync = true;
@ -246,8 +256,8 @@ pub trait SiteHooks: Send + Sync + 'static {
/// A tab webview was created.
fn tab_created(&self, _app: &AppHandle) {}
/// A Google font was installed or removed ([`commands::GFONTS`]): give the site tabs their
/// theme, and the font bytes, again.
/// 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
@ -1246,6 +1256,11 @@ fn dev_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'st
crate::dev_commands::handler()
}
/// 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()
@ -1314,6 +1329,7 @@ impl App {
let update_handler = update_handler();
let sync_handler = sync_handler();
let dev_handler = dev_handler();
let theme_handler = theme_handler();
let backup_handler = backup_handler();
let builder = tauri::Builder::<Wry>::default()
// Must be first: a second launch focuses the running window instead of fighting over
@ -1397,6 +1413,7 @@ impl App {
crate::media_commands::init(&handle);
crate::sync_commands::init(&handle);
crate::dev_commands::init(&handle);
crate::theme_commands::init(&handle);
#[cfg(feature = "net")]
crate::fonts_commands::init(&handle);
#[cfg(feature = "net")]
@ -1429,6 +1446,8 @@ impl App {
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::is_host(invoke.message.command()) {
host_handler(invoke)
} else {

View file

@ -118,9 +118,14 @@ pub const DEV: &[&str] = &["dev_get", "dev_set", "dev_pick_theme", "dev_clear_th
/// (`SiteHooks::backup_sections`, `backup_drafts`, `backup_apply`).
pub const BACKUP: &[&str] = &["backup_collect", "backup_export_file", "backup_open_file", "backup_apply"];
/// Shell-only, optional: site themes (the owner's theme, account and pane themes, the Theme
/// Builder's live preview), served when the app sets `AppConfig::themes`. An app that uses them
/// grants these to the shell (`testkit::Capabilities::assert_shell_only_group`).
pub const THEME: &[&str] = &["theme_get", "theme_set", "theme_preview", "theme_scopes_get", "theme_scopes_set"];
/// Whether `command` is one of the host's (dispatched to the host, never to the app's handler).
pub fn is_host(command: &str) -> bool {
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command) || MEDIA.contains(&command) || GFONTS.contains(&command) || UPDATE.contains(&command) || WORKSPACES.contains(&command) || SYNC.contains(&command) || DEV.contains(&command) || BACKUP.contains(&command)
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command) || MEDIA.contains(&command) || GFONTS.contains(&command) || UPDATE.contains(&command) || WORKSPACES.contains(&command) || SYNC.contains(&command) || DEV.contains(&command) || BACKUP.contains(&command) || THEME.contains(&command)
}
/// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`).
@ -135,8 +140,8 @@ pub fn permission(command: &str) -> String {
/// tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&["draft_list"]))
/// ```
pub fn manifest(app: &[&'static str]) -> &'static [&'static str] {
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len() + MEDIA.len() + GFONTS.len() + UPDATE.len() + WORKSPACES.len() + SYNC.len() + DEV.len() + BACKUP.len());
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC).chain(DEV).chain(BACKUP) {
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len() + MEDIA.len() + GFONTS.len() + UPDATE.len() + WORKSPACES.len() + SYNC.len() + DEV.len() + BACKUP.len() + THEME.len());
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC).chain(DEV).chain(BACKUP).chain(THEME) {
if !all.contains(command) {
all.push(command);
}
@ -153,7 +158,7 @@ mod tests {
let all = manifest(&["draft_list", "tabs_sync"]);
assert_eq!(all[0], "draft_list");
assert_eq!(all.iter().filter(|c| **c == "tabs_sync").count(), 1);
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC).chain(DEV).chain(BACKUP) {
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC).chain(DEV).chain(BACKUP).chain(THEME) {
assert!(all.contains(command), "{command}");
assert!(is_host(command));
}
@ -163,7 +168,7 @@ mod tests {
#[test]
fn shell_and_tab_commands_do_not_overlap() {
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command) || MEDIA.contains(command) || GFONTS.contains(command) || UPDATE.contains(command) || WORKSPACES.contains(command) || SYNC.contains(command) || DEV.contains(command) || BACKUP.contains(command)));
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command) || MEDIA.contains(command) || GFONTS.contains(command) || UPDATE.contains(command) || WORKSPACES.contains(command) || SYNC.contains(command) || DEV.contains(command) || BACKUP.contains(command) || THEME.contains(command)));
assert!(!TAB.iter().any(|command| SITE.contains(command)));
}
}

View file

@ -23,6 +23,7 @@ pub(crate) fn init(app: &AppHandle) {
}
fn changed(app: &AppHandle) {
crate::theme_commands::apply(app);
crate::app::hooks(app).fonts_changed(app);
}

View file

@ -91,6 +91,8 @@ mod sync_commands;
#[cfg(feature = "app")]
mod dev_commands;
#[cfg(feature = "app")]
pub mod theme_commands;
#[cfg(feature = "app")]
mod backup_commands;
#[cfg(all(feature = "app", feature = "net"))]
mod fonts_commands;

View file

@ -259,6 +259,14 @@ pub fn assert_dev_guards() {
}
}
/// The theme commands' wrappers check their caller first. Runs against the host's source.
pub fn assert_theme_guards() {
let source = include_str!("theme_commands.rs");
for command in commands::THEME {
assert_guarded(source, command, "shell_only(&webview)?");
}
}
/// The backup commands' wrappers check their caller first. Runs against the host's source.
pub fn assert_backup_guards() {
let source = include_str!("backup_commands.rs");
@ -280,6 +288,7 @@ mod tests {
assert_sync_guards();
assert_dev_guards();
assert_backup_guards();
assert_theme_guards();
}
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {

View file

@ -0,0 +1,233 @@
//! Site themes, native side, for every app that sets [`crate::app::AppConfig::themes`]: the
//! shell-only `theme_*` commands ([`crate::commands::THEME`]) over [`crate::theme`]'s state (the
//! owner's theme, the account and pane themes, the Theme Builder's preview). The app only names
//! its built-in themes ([`ThemeConfig`]) and the two files they are saved in.
//!
//! Site tabs gain no command: native code evaluates the theme script (and the cached Google font
//! bytes it needs) in every site tab and sets each tab's native background to its theme's page color.
//! The app's own writers (a backup import, developer mode's theme file) use [`set_theme`],
//! [`set_scopes`] and [`set_preview`], then [`apply`].
use crate::app::{config, tab_profile};
use crate::host::{data_file, read_json, shell_only, write_json};
use crate::theme::{ThemeChoice, ThemeScopes, ThemeSite};
use std::sync::{Mutex, MutexGuard};
use tauri::{AppHandle, Manager, Webview};
/// What an app declares about its themes: the built-in ones and where the owner's choice is saved
/// (app data file names).
#[derive(Clone, Copy, Debug)]
pub struct ThemeConfig {
pub site: &'static ThemeSite,
/// The owner's theme choice (absent = the site's own look).
pub file: &'static str,
/// Account and pane themes and the chrome setting (absent = defaults).
pub scopes_file: &'static str,
}
#[derive(Default)]
struct Themes {
theme: Option<ThemeChoice>,
scopes: ThemeScopes,
/// The Theme Builder's unsaved draft, shown in every site tab until it ends.
preview: Option<ThemeChoice>,
}
struct ThemeState(Mutex<Themes>);
/// Loads the saved theme and scopes (anything that no longer validates is dropped). Does nothing
/// unless the app set [`crate::app::AppConfig::themes`].
pub(crate) fn init(app: &AppHandle) {
let Some(themes) = config(app).themes else { return };
let theme = read_json::<ThemeChoice>(app, themes.file).filter(|theme| theme.validate(themes.site).is_ok());
let scopes = read_json::<ThemeScopes>(app, themes.scopes_file).filter(|scopes| scopes.validate().is_ok()).unwrap_or_default();
app.manage(ThemeState(Mutex::new(Themes { theme, scopes, preview: None })));
}
fn themes(app: &AppHandle) -> Result<&'static ThemeConfig, String> {
config(app).themes.ok_or_else(|| "UNAVAILABLE: this app has no themes".to_owned())
}
fn state(app: &AppHandle) -> Result<MutexGuard<'_, Themes>, String> {
app.try_state::<ThemeState>()
.ok_or("DATABASE: theme state unavailable; restart safely")?
.inner()
.0
.lock()
.map_err(|_| "DATABASE: theme state unavailable; restart safely".into())
}
/// The theme the owner chose (the site's own look when none).
pub fn current(app: &AppHandle) -> Result<ThemeChoice, String> {
let themes = themes(app)?;
Ok(state(app)?.theme.clone().unwrap_or_else(|| ThemeChoice::default_for(themes.site)))
}
/// Account and pane themes and the chrome setting.
pub fn scopes(app: &AppHandle) -> Result<ThemeScopes, String> {
Ok(state(app)?.scopes.clone())
}
/// The theme for site tab webview `label`: the Theme Builder's preview while one is open, else its
/// pane's, its account's or the global theme.
fn tab_theme(app: &AppHandle, themes: &ThemeConfig, state: &Themes, label: &str) -> ThemeChoice {
let global = state.theme.clone().unwrap_or_else(|| ThemeChoice::default_for(themes.site));
match &state.preview {
Some(preview) => preview.clone(),
None => {
let tab = crate::policy::tab_id(label).unwrap_or_default();
state.scopes.for_tab(themes.site, &global, tab, tab_profile(app, label).as_deref())
}
}
}
fn background(theme: &ThemeChoice, themes: &ThemeConfig) -> Option<tauri::window::Color> {
theme.page_color(themes.site).map(|(r, g, b)| tauri::window::Color(r, g, b, 255))
}
fn scripts_for(app: &AppHandle, themes: &ThemeConfig, theme: &ThemeChoice) -> Vec<String> {
let site = config(app).site;
let mut scripts = Vec::new();
#[cfg(feature = "net")]
if let Some(fonts) = crate::fonts::page_script_for(site, &theme.google_fonts()) {
scripts.push(fonts);
}
if let Ok(script) = theme.script(themes.site, site) {
scripts.push(script);
}
scripts
}
/// The native background of site tab `label` until its page paints: its theme's page color.
pub fn tab_background(app: &AppHandle, label: &str) -> Option<tauri::window::Color> {
let themes = config(app).themes?;
let state = state(app).ok()?;
background(&tab_theme(app, themes, &state, label), themes)
}
/// The scripts a freshly loaded site document in tab `label` gets for its theme (and the font
/// bytes it needs); none while the site keeps its own look.
pub fn page_scripts(app: &AppHandle, label: &str) -> Vec<String> {
let Some(themes) = config(app).themes else { return Vec::new() };
let Ok(state) = state(app) else { return Vec::new() };
if state.theme.is_none() && state.preview.is_none() && state.scopes.is_default() {
return Vec::new();
}
scripts_for(app, themes, &tab_theme(app, themes, &state, label))
}
/// Sends every site tab its theme again (after the theme, the scopes, the preview or the fonts
/// changed). In safe mode site pages keep the site's own look; a theme chosen meanwhile applies
/// when safe mode is left.
pub fn apply(app: &AppHandle) {
if crate::safe::active() {
return;
}
let Some(themes) = config(app).themes else { return };
let site = config(app).site;
let per_tab: Vec<(Webview, ThemeChoice)> = {
let Ok(state) = state(app) else { return };
app.webviews()
.into_iter()
.filter(|(label, tab)| crate::policy::tab_id(label).is_some() && tab.url().is_ok_and(|url| site.is_origin(&url)))
.map(|(label, tab)| (tab, tab_theme(app, themes, &state, &label)))
.collect()
};
for (tab, theme) in per_tab {
let _ = tab.set_background_color(background(&theme, themes));
for script in scripts_for(app, themes, &theme) {
let _ = tab.eval(&script);
}
}
}
/// Saves the owner's theme (checked against the built-in ones; `None` or an unchanged site look
/// removes the file). Does not apply it: call [`apply`].
pub fn set_theme(app: &AppHandle, theme: Option<ThemeChoice>) -> Result<(), String> {
let themes = themes(app)?;
let theme = theme.unwrap_or_else(|| ThemeChoice::default_for(themes.site));
theme.validate(themes.site)?;
if theme.is_empty(themes.site) {
if let Some(path) = data_file(app, themes.file) {
let _ = std::fs::remove_file(path);
}
state(app)?.theme = None;
} else {
write_json(app, themes.file, &theme)?;
state(app)?.theme = Some(theme);
}
Ok(())
}
/// Saves the account and pane themes (bounded, ids checked). Does not apply them: call [`apply`].
pub fn set_scopes(app: &AppHandle, scopes: ThemeScopes) -> Result<(), String> {
let themes = themes(app)?;
scopes.validate()?;
if scopes.is_default() {
if let Some(path) = data_file(app, themes.scopes_file) {
let _ = std::fs::remove_file(path);
}
} else {
write_json(app, themes.scopes_file, &scopes)?;
}
state(app)?.scopes = scopes;
Ok(())
}
/// Shows an unsaved theme in every site tab (not persisted); `None` ends the preview. Does not
/// apply it: call [`apply`].
pub fn set_preview(app: &AppHandle, theme: Option<ThemeChoice>) -> Result<(), String> {
let themes = themes(app)?;
if let Some(theme) = &theme {
theme.validate(themes.site)?;
}
state(app)?.preview = theme;
Ok(())
}
/// Applies a theme to every site tab and persists it. `None` (or the site's own preset without an
/// accent) restores the site's own look.
#[tauri::command]
pub(crate) async fn theme_set(webview: Webview, app: AppHandle, theme: Option<ThemeChoice>) -> Result<(), String> {
shell_only(&webview)?;
set_theme(&app, theme)?;
apply(&app);
Ok(())
}
/// The persisted theme (the site's own look when none).
#[tauri::command]
pub(crate) async fn theme_get(webview: Webview, app: AppHandle) -> Result<ThemeChoice, String> {
shell_only(&webview)?;
current(&app)
}
/// Account and pane themes and the chrome setting.
#[tauri::command]
pub(crate) async fn theme_scopes_get(webview: Webview, app: AppHandle) -> Result<ThemeScopes, String> {
shell_only(&webview)?;
scopes(&app)
}
/// Saves account and pane themes and re-themes every site tab.
#[tauri::command]
pub(crate) async fn theme_scopes_set(webview: Webview, app: AppHandle, scopes: ThemeScopes) -> Result<(), String> {
shell_only(&webview)?;
set_scopes(&app, scopes)?;
apply(&app);
Ok(())
}
/// The Theme Builder's live preview: an unsaved theme shown in every site tab. `None` ends the
/// preview and gives every tab its own theme back.
#[tauri::command]
pub(crate) async fn theme_preview(webview: Webview, app: AppHandle, theme: Option<ThemeChoice>) -> Result<(), String> {
shell_only(&webview)?;
set_preview(&app, theme)?;
apply(&app);
Ok(())
}
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![theme_get, theme_set, theme_preview, theme_scopes_get, theme_scopes_set]
}