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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 16:17:30 +07:00
parent fc08bc89cb
commit 400f22c40c
6 changed files with 147 additions and 11 deletions

View file

@ -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<Wry>) -> 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<Wry>) -> bool + Send + Sync + 'static {
#[cfg(feature = "vault")]
return crate::vault_commands::handler();
#[cfg(not(feature = "vault"))]
|_: tauri::ipc::Invoke<Wry>| false
}
/// The theme commands ([`commands::THEME`]).
fn theme_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> 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::<Wry>::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")),
_ => {}
}
}

View file

@ -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");
}

View file

@ -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"))]

View file

@ -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 {

View file

@ -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],

View file

@ -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<Vault>);
/// 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<dyn std::error::Error>> {
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<MutexGuard<'_, Vault>, String> {
app.try_state::<VaultState>()
.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<Vec<Draft>, String> {
shell_only(&webview)?;
lock(&app)?.list_drafts()
}
#[tauri::command]
pub(crate) async fn draft_save(webview: Webview, app: AppHandle, input: SaveDraftInput) -> Result<Draft, String> {
shell_only(&webview)?;
lock(&app)?.save_draft(&input)
}
#[tauri::command]
pub(crate) async fn draft_revisions(webview: Webview, app: AppHandle, draft_id: String) -> Result<Vec<DraftRevision>, 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<Draft, String> {
shell_only(&webview)?;
lock(&app)?.set_status(&id, expected_revision, &status)
}
#[tauri::command]
pub(crate) async fn observation_list(webview: Webview, app: AppHandle) -> Result<Vec<Observation>, String> {
shell_only(&webview)?;
lock(&app)?.observations()
}
#[tauri::command]
pub(crate) async fn data_export(webview: Webview, app: AppHandle) -> Result<ExportData, String> {
shell_only(&webview)?;
lock(&app)?.export()
}
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![draft_list, draft_save, draft_revisions, draft_status, observation_list, data_export]
}