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:
parent
d47a77d95d
commit
1539cb68e1
5 changed files with 128 additions and 22 deletions
85
crates/waybetter-host/src/alerts_commands.rs
Normal file
85
crates/waybetter-host/src/alerts_commands.rs
Normal 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]
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue