From 8c2404b4cca4b994c25a530c61748610a9a42d7f Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 1 Oct 2026 16:17:30 +0700 Subject: [PATCH] host: the drafts vault commands (draft_list/save/revisions/status, observation_list, data_export) are the host's (AppConfig::with_vault, commands::VAULT); the app takes the vault through vault_commands::lock --- crates/waybetter-host/src/app.rs | 44 +++++++++++- crates/waybetter-host/src/commands.rs | 21 +++--- crates/waybetter-host/src/lib.rs | 2 + crates/waybetter-host/src/testkit.rs | 9 +++ crates/waybetter-host/src/vault.rs | 2 +- crates/waybetter-host/src/vault_commands.rs | 80 +++++++++++++++++++++ 6 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 crates/waybetter-host/src/vault_commands.rs diff --git a/crates/waybetter-host/src/app.rs b/crates/waybetter-host/src/app.rs index cbb5d22..6c47ebe 100644 --- a/crates/waybetter-host/src/app.rs +++ b/crates/waybetter-host/src/app.rs @@ -102,6 +102,10 @@ pub struct AppConfig { /// 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>, + /// The drafts vault (a SQLite database in app data with CAS revisions): serves + /// [`commands::VAULT`]; the rules (kinds, limits, capture ownership) are the app's. + #[cfg(feature = "vault")] + pub vault: Option<&'static crate::vault_commands::VaultConfig>, /// 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")] @@ -134,6 +138,8 @@ impl AppConfig { dev: false, themes: None, alerts: None, + #[cfg(feature = "vault")] + vault: None, #[cfg(feature = "net")] updates: None, } @@ -164,6 +170,13 @@ impl AppConfig { self } + /// Serves the drafts vault commands ([`commands::VAULT`]) over this database and rules. + #[cfg(feature = "vault")] + pub const fn with_vault(mut self, vault: &'static crate::vault_commands::VaultConfig) -> Self { + self.vault = Some(vault); + self + } + /// Serves the sync commands ([`commands::SYNC`]). pub const fn with_sync(mut self) -> Self { self.sync = true; @@ -1281,6 +1294,14 @@ fn alerts_handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + crate::alerts_commands::handler() } +/// The drafts vault commands ([`commands::VAULT`]); without the `vault` feature none are served. +fn vault_handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { + #[cfg(feature = "vault")] + return crate::vault_commands::handler(); + #[cfg(not(feature = "vault"))] + |_: tauri::ipc::Invoke| false +} + /// The theme commands ([`commands::THEME`]). fn theme_handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { crate::theme_commands::handler() @@ -1356,6 +1377,7 @@ impl App { let dev_handler = dev_handler(); let theme_handler = theme_handler(); let alerts_handler = alerts_handler(); + let vault_handler = vault_handler(); let backup_handler = backup_handler(); let builder = tauri::Builder::::default() // Must be first: a second launch focuses the running window instead of fighting over @@ -1444,6 +1466,8 @@ impl App { crate::dev_commands::init(&handle); crate::theme_commands::init(&handle); crate::alerts_commands::init(&handle); + #[cfg(feature = "vault")] + crate::vault_commands::init(&handle)?; #[cfg(feature = "net")] crate::fonts_commands::init(&handle); #[cfg(feature = "net")] @@ -1480,14 +1504,30 @@ impl App { theme_handler(invoke) } else if commands::ALERTS.contains(&invoke.message.command()) { alerts_handler(invoke) + } else if commands::VAULT.contains(&invoke.message.command()) { + vault_handler(invoke) } else if commands::is_host(invoke.message.command()) { host_handler(invoke) } else { handler(invoke) } }) - .run(context) - .unwrap_or_else(|error| panic!("Failed to launch {}: {error}", config.title)); + .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")), + _ => {} } } diff --git a/crates/waybetter-host/src/commands.rs b/crates/waybetter-host/src/commands.rs index 82e6346..02518e4 100644 --- a/crates/waybetter-host/src/commands.rs +++ b/crates/waybetter-host/src/commands.rs @@ -130,9 +130,14 @@ pub const THEME: &[&str] = &["theme_get", "theme_set", "theme_preview", "theme_s /// (`testkit::Capabilities::assert_shell_only_group`). pub const ALERTS: &[&str] = &["alerts_get", "alerts_set"]; +/// Shell-only, optional: the drafts vault (list, save with a revision check, revisions, archive and +/// restore, observations, one consistent export), served when the app sets `AppConfig::vault`. An app +/// that uses them grants these to the shell (`testkit::Capabilities::assert_shell_only_group`). +pub const VAULT: &[&str] = &["draft_list", "draft_save", "draft_revisions", "draft_status", "observation_list", "data_export"]; + /// 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) || ALERTS.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) || VAULT.contains(&command) } /// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`). @@ -144,11 +149,11 @@ pub fn permission(command: &str) -> String { /// once). Leaked on purpose: tauri-build wants `&'static` and a build script runs once. /// /// ```ignore -/// tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&["draft_list"])) +/// tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&["capture_draft"])) /// ``` 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() + 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) { + 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() + VAULT.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).chain(VAULT) { if !all.contains(command) { all.push(command); } @@ -162,14 +167,14 @@ mod tests { #[test] fn manifest_adds_the_host_commands_once() { - let all = manifest(&["draft_list", "tabs_sync"]); - assert_eq!(all[0], "draft_list"); + let all = manifest(&["capture_draft", "tabs_sync"]); + assert_eq!(all[0], "capture_draft"); 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).chain(ALERTS) { + 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).chain(VAULT) { assert!(all.contains(command), "{command}"); assert!(is_host(command)); } - assert!(!is_host("draft_list")); + assert!(!is_host("capture_draft")); assert_eq!(permission("tab_link_drag"), "allow-tab-link-drag"); } diff --git a/crates/waybetter-host/src/lib.rs b/crates/waybetter-host/src/lib.rs index 05ec5e4..3678f73 100644 --- a/crates/waybetter-host/src/lib.rs +++ b/crates/waybetter-host/src/lib.rs @@ -99,6 +99,8 @@ pub mod theme_commands; pub mod tab_commands; #[cfg(feature = "app")] pub mod alerts_commands; +#[cfg(all(feature = "app", feature = "vault"))] +pub mod vault_commands; #[cfg(feature = "app")] mod backup_commands; #[cfg(all(feature = "app", feature = "net"))] diff --git a/crates/waybetter-host/src/testkit.rs b/crates/waybetter-host/src/testkit.rs index 169feb4..2aee7e7 100644 --- a/crates/waybetter-host/src/testkit.rs +++ b/crates/waybetter-host/src/testkit.rs @@ -280,6 +280,14 @@ pub fn assert_alerts_guards() { } } +/// The vault commands' wrappers check their caller first. Runs against the host's source. +pub fn assert_vault_guards() { + let source = include_str!("vault_commands.rs"); + for command in commands::VAULT { + 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"); @@ -303,6 +311,7 @@ mod tests { assert_backup_guards(); assert_theme_guards(); assert_alerts_guards(); + assert_vault_guards(); } fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities { diff --git a/crates/waybetter-host/src/vault.rs b/crates/waybetter-host/src/vault.rs index 6086b10..2641bf1 100644 --- a/crates/waybetter-host/src/vault.rs +++ b/crates/waybetter-host/src/vault.rs @@ -28,7 +28,7 @@ pub struct CaptureRules { } /// What an app decides about its drafts. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] pub struct VaultRules { pub kinds: &'static [&'static str], pub sources: &'static [&'static str], diff --git a/crates/waybetter-host/src/vault_commands.rs b/crates/waybetter-host/src/vault_commands.rs new file mode 100644 index 0000000..76c6b27 --- /dev/null +++ b/crates/waybetter-host/src/vault_commands.rs @@ -0,0 +1,80 @@ +//! The drafts vault, native side, for every app that sets [`crate::app::AppConfig::vault`]: opens the +//! app's [`Vault`] in app data at launch and serves the shell-only `draft_list`, `draft_save`, +//! `draft_revisions`, `draft_status`, `observation_list` and `data_export` ([`crate::commands::VAULT`]). +//! A write is acknowledged only after its SQLite transaction committed. The app's own native code (its +//! capture, a popover that reads drafts, a backup import) takes the same vault through [`lock`]; what +//! it does with it is the app's. + +use crate::app::config; +use crate::host::{data_file, shell_only}; +use crate::vault::{Draft, DraftRevision, ExportData, Observation, SaveDraftInput, Vault, VaultRules}; +use std::sync::{Mutex, MutexGuard}; +use tauri::{AppHandle, Manager, Webview}; + +/// What an app declares about its vault: the database file in app data and its rules. +#[derive(Clone, Copy, Debug)] +pub struct VaultConfig { + pub file: &'static str, + pub rules: &'static VaultRules, +} + +struct VaultState(Mutex); + +/// Opens the database. Does nothing unless the app set [`crate::app::AppConfig::vault`]. A database +/// that cannot be opened (a newer schema, a damaged file) stops the launch rather than starting empty. +pub(crate) fn init(app: &AppHandle) -> Result<(), Box> { + let Some(vault) = config(app).vault else { return Ok(()) }; + let path = data_file(app, vault.file).ok_or("STORAGE: no application data directory")?; + app.manage(VaultState(Mutex::new(Vault::open(path, vault.rules).map_err(std::io::Error::other)?))); + Ok(()) +} + +/// The vault, locked. Hold it only for the call that needs it; never across a webview call. +pub fn lock(app: &AppHandle) -> Result, String> { + app.try_state::() + .ok_or("DATABASE: vault unavailable; restart safely")? + .inner() + .0 + .lock() + .map_err(|_| "DATABASE: workspace state unavailable; restart safely".into()) +} + +#[tauri::command] +pub(crate) async fn draft_list(webview: Webview, app: AppHandle) -> Result, String> { + shell_only(&webview)?; + lock(&app)?.list_drafts() +} + +#[tauri::command] +pub(crate) async fn draft_save(webview: Webview, app: AppHandle, input: SaveDraftInput) -> Result { + shell_only(&webview)?; + lock(&app)?.save_draft(&input) +} + +#[tauri::command] +pub(crate) async fn draft_revisions(webview: Webview, app: AppHandle, draft_id: String) -> Result, String> { + shell_only(&webview)?; + lock(&app)?.revisions(&draft_id) +} + +#[tauri::command] +pub(crate) async fn draft_status(webview: Webview, app: AppHandle, id: String, expected_revision: i64, status: String) -> Result { + shell_only(&webview)?; + lock(&app)?.set_status(&id, expected_revision, &status) +} + +#[tauri::command] +pub(crate) async fn observation_list(webview: Webview, app: AppHandle) -> Result, String> { + shell_only(&webview)?; + lock(&app)?.observations() +} + +#[tauri::command] +pub(crate) async fn data_export(webview: Webview, app: AppHandle) -> Result { + shell_only(&webview)?; + lock(&app)?.export() +} + +pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { + tauri::generate_handler![draft_list, draft_save, draft_revisions, draft_status, observation_list, data_export] +}