host: keyword alert settings are the host's (AppConfig::with_alerts, commands::ALERTS)

alerts_commands keeps the owner's AlertSettings (saved in app data, validated by alerts), serves
the shell-only alerts_get / alerts_set (testkit::assert_alerts_guards) and builds the script that
hands the page runtime the keywords to watch. The app names the file and the runtime member
(AlertsConfig); store()/script() are the app's own writers (backup import).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 14:14:34 +07:00
parent d47a77d95d
commit 1539cb68e1
5 changed files with 128 additions and 22 deletions

View file

@ -0,0 +1,85 @@
//! Keyword alert settings, native side, for every app that sets [`crate::app::AppConfig::alerts`]:
//! the shell-only `alerts_get` / `alerts_set` ([`crate::commands::ALERTS`]) over the owner's
//! [`AlertSettings`] (saved in app data, validated by [`crate::alerts`]). Site tabs gain no command
//! here: native code hands the page runtime the keywords to watch ([`script`]) at every page load
//! and whenever they change. What a match looks like and the notification are the app's.
use crate::alerts::AlertSettings;
use crate::app::config;
use crate::host::{eval_in_site_tabs, read_json, shell_only, write_json};
use crate::tabs::deliver_script;
use std::sync::{Mutex, MutexGuard};
use tauri::{AppHandle, Manager, Webview};
/// What an app declares about its alerts: where they are saved and how the page runtime takes the
/// keywords (`window.<global>.<member>.set(keywords)`, else parked in `window.<queue>`).
#[derive(Clone, Copy, Debug)]
pub struct AlertsConfig {
pub file: &'static str,
pub member: &'static str,
pub queue: &'static str,
}
struct AlertState(Mutex<AlertSettings>);
/// Loads the saved settings. Does nothing unless the app set [`crate::app::AppConfig::alerts`].
pub(crate) fn init(app: &AppHandle) {
let Some(alerts) = config(app).alerts else { return };
let settings = read_json::<AlertSettings>(app, alerts.file).and_then(|settings| settings.validated().ok()).unwrap_or_default();
app.manage(AlertState(Mutex::new(settings)));
}
fn state(app: &AppHandle) -> Result<MutexGuard<'_, AlertSettings>, String> {
app.try_state::<AlertState>()
.ok_or("DATABASE: alert state unavailable; restart safely")?
.inner()
.0
.lock()
.map_err(|_| "DATABASE: alert state unavailable; restart safely".into())
}
/// The owner's alert settings.
pub fn settings(app: &AppHandle) -> Result<AlertSettings, String> {
Ok(state(app)?.clone())
}
/// The script that hands the page runtime the keywords to watch (empty = alerts off).
pub fn script(app: &AppHandle) -> String {
let (Some(alerts), Ok(settings)) = (config(app).alerts, settings(app)) else { return String::new() };
script_for(app, alerts, &settings)
}
fn script_for(app: &AppHandle, alerts: &AlertsConfig, settings: &AlertSettings) -> String {
let keywords = serde_json::to_string(settings.active_keywords()).unwrap_or_else(|_| "[]".into());
deliver_script(config(app).site.runtime_global, alerts.member, "set", alerts.queue, &keywords)
}
/// Validates, saves and keeps the settings, and does not touch the tabs: for the app's own
/// writers (a backup import), which then evaluate [`script`] themselves.
pub fn store(app: &AppHandle, settings: AlertSettings) -> Result<AlertSettings, String> {
let alerts = config(app).alerts.ok_or_else(|| "UNAVAILABLE: this app has no alerts".to_owned())?;
let settings = settings.validated()?;
// The lock is held across the write so concurrent saves cannot leave disk and memory disagreeing.
let mut current = state(app)?;
write_json(app, alerts.file, &settings)?;
*current = settings.clone();
Ok(settings)
}
#[tauri::command]
pub(crate) async fn alerts_get(webview: Webview, app: AppHandle) -> Result<AlertSettings, String> {
shell_only(&webview)?;
settings(&app)
}
#[tauri::command]
pub(crate) async fn alerts_set(webview: Webview, app: AppHandle, settings: AlertSettings) -> Result<AlertSettings, String> {
shell_only(&webview)?;
let settings = store(&app, settings)?;
eval_in_site_tabs(&app, config(&app).site, &script(&app));
Ok(settings)
}
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![alerts_get, alerts_set]
}

View file

@ -99,6 +99,9 @@ pub struct AppConfig {
/// 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>,
/// 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")]
@ -130,6 +133,7 @@ impl AppConfig {
sync: false,
dev: false,
themes: None,
alerts: None,
#[cfg(feature = "net")]
updates: None,
}
@ -154,6 +158,12 @@ impl AppConfig {
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 sync commands ([`commands::SYNC`]).
pub const fn with_sync(mut self) -> Self {
self.sync = true;
@ -699,7 +709,7 @@ async fn tab_zoom(webview: Webview, app: AppHandle, id: String, factor: Option<f
/// 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.
/// evaluated `window.find`. 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)?;
@ -1256,6 +1266,11 @@ fn dev_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'st
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 theme commands ([`commands::THEME`]).
fn theme_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
crate::theme_commands::handler()
@ -1330,6 +1345,7 @@ impl App {
let sync_handler = sync_handler();
let dev_handler = dev_handler();
let theme_handler = theme_handler();
let alerts_handler = alerts_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
@ -1414,6 +1430,7 @@ impl App {
crate::sync_commands::init(&handle);
crate::dev_commands::init(&handle);
crate::theme_commands::init(&handle);
crate::alerts_commands::init(&handle);
#[cfg(feature = "net")]
crate::fonts_commands::init(&handle);
#[cfg(feature = "net")]
@ -1448,28 +1465,16 @@ impl App {
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::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(|_, event| log_run_event(&event));
}
}
/// 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 log_run_event(event: &tauri::RunEvent) {
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")),
_ => {}
.run(context)
.unwrap_or_else(|error| panic!("Failed to launch {}: {error}", config.title));
}
}

View file

@ -123,9 +123,14 @@ pub const BACKUP: &[&str] = &["backup_collect", "backup_export_file", "backup_op
/// 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"];
/// Shell-only, optional: the keyword alert settings (`alerts_get`, `alerts_set`), served when the app
/// sets `AppConfig::alerts`. An app that uses them grants these to the shell
/// (`testkit::Capabilities::assert_shell_only_group`).
pub const ALERTS: &[&str] = &["alerts_get", "alerts_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) || THEME.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) || ALERTS.contains(&command)
}
/// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`).
@ -140,8 +145,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() + 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) {
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() + ALERTS.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).chain(ALERTS) {
if !all.contains(command) {
all.push(command);
}
@ -158,7 +163,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).chain(THEME) {
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).chain(ALERTS) {
assert!(all.contains(command), "{command}");
assert!(is_host(command));
}
@ -168,7 +173,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) || THEME.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) || ALERTS.contains(command)));
assert!(!TAB.iter().any(|command| SITE.contains(command)));
}
}

View file

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

View file

@ -267,6 +267,14 @@ pub fn assert_theme_guards() {
}
}
/// The alert settings' wrappers check their caller first. Runs against the host's source.
pub fn assert_alerts_guards() {
let source = include_str!("alerts_commands.rs");
for command in commands::ALERTS {
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");
@ -289,6 +297,7 @@ mod tests {
assert_dev_guards();
assert_backup_guards();
assert_theme_guards();
assert_alerts_guards();
}
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {