Host: the settings backup commands (collect, export, open, apply) are served for every app; SiteHooks::backup_drafts and backup_apply carry what only the app knows; commands::BACKUP, testkit::assert_backup_guards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 04:41:12 +07:00
parent 198b1160f2
commit 409949edb9
5 changed files with 188 additions and 5 deletions

View file

@ -262,6 +262,18 @@ pub trait SiteHooks: Send + Sync + 'static {
&[]
}
/// The `drafts` backup section (only when the owner ticks "include drafts"), or none. By default none.
fn backup_drafts(&self, _app: &AppHandle) -> Result<Option<serde_json::Value>, String> {
Ok(None)
}
/// Applies the sections of an import the owner chose ([`commands::BACKUP`]): check each the way
/// its own setter does, write nothing if any is bad, and return what was applied and notes for
/// the owner (`{ applied: [..], notes: [..] }`). By default importing is not supported.
fn backup_apply(&self, _app: &AppHandle, _sections: &serde_json::Map<String, serde_json::Value>) -> Result<serde_json::Value, String> {
Err("UNAVAILABLE: this app cannot import a backup".into())
}
/// Developer mode: show the theme file the owner picked (read `path`, make it the theme preview
/// in every site tab). An error is shown with its file and line. By default themes are not
/// supported and this does nothing.
@ -1198,6 +1210,11 @@ fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + '
|_| false
}
/// The backup commands ([`commands::BACKUP`]).
fn backup_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
crate::backup_commands::handler()
}
/// The developer-mode commands ([`commands::DEV`]).
fn dev_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
crate::dev_commands::handler()
@ -1271,6 +1288,7 @@ impl App {
let update_handler = update_handler();
let sync_handler = sync_handler();
let dev_handler = dev_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
// the app's data and WebView profile (and hands over its link).
@ -1383,6 +1401,8 @@ impl App {
sync_handler(invoke)
} else if commands::DEV.contains(&invoke.message.command()) {
dev_handler(invoke)
} else if commands::BACKUP.contains(&invoke.message.command()) {
backup_handler(invoke)
} else if commands::is_host(invoke.message.command()) {
host_handler(invoke)
} else {

View file

@ -0,0 +1,147 @@
//! Settings backup, native side ([`crate::commands::BACKUP`], served for every app; an app that
//! does not use them grants them to no one): everything the owner set up in one versioned `.waybetter` file.
//! Every command is shell-only. The envelope, the secret scrubbing and the validation are
//! [`crate::backup`]'s; this module asks for what to save and writes the file.
//!
//! What a backup carries is the app's: its native sections
//! ([`crate::app::SiteHooks::backup_sections`]), its drafts only when the owner ticks "include
//! drafts" ([`crate::app::SiteHooks::backup_drafts`]) and the shell's own storage (`decks`,
//! `shell`). Importing shows the sections for a preview first (nothing is applied); the app applies
//! the ones the owner chose ([`crate::app::SiteHooks::backup_apply`]), each checked as its own
//! setter checks it. Never included: cookies, WebView profile folders, the session database,
//! tokens, signing keys, the sync key, update state, font files, gallery and clipboard data.
use crate::app::{config, hooks, tr};
use crate::backup;
use crate::host::shell_only;
use serde::Serialize;
use serde_json::{json, Map, Value};
use tauri::{AppHandle, Webview};
use tauri_plugin_dialog::DialogExt;
/// The app's native sections (plus drafts when asked) and `extra` sections from the shell
/// (`decks`), secret-looking keys removed.
fn collect(app: &AppHandle, include_drafts: bool, extra: Option<Map<String, Value>>) -> Result<Map<String, Value>, String> {
let hooks = hooks(app);
let mut sections = hooks.backup_sections(app)?;
if include_drafts {
if let Some(drafts) = hooks.backup_drafts(app)? {
sections.insert("drafts".into(), drafts);
}
}
for (key, value) in extra.unwrap_or_default() {
// The shell only adds its own storage; it cannot replace a native section or add drafts.
if (key == "decks" || key == "shell") && value.is_object() {
sections.insert(key, value);
}
}
let mut value = Value::Object(sections);
backup::strip_secrets(&mut value);
Ok(match value {
Value::Object(map) => map,
_ => Map::new(),
})
}
fn envelope(app: &AppHandle, sections: Map<String, Value>) -> Value {
json!({
"format": backup::FORMAT,
"version": backup::VERSION,
"app": config(app).title,
"appVersion": app.package_info().version.to_string(),
"createdAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
"includesDrafts": sections.contains_key("drafts"),
"sections": sections,
})
}
/// The current settings as a backup would carry them (for the import preview's diff).
#[tauri::command]
pub(crate) async fn backup_collect(webview: Webview, app: AppHandle, include_drafts: bool) -> Result<Map<String, Value>, String> {
shell_only(&webview)?;
collect(&app, include_drafts, None)
}
/// Asks where to save and writes the `.waybetter` file. Returns the path, or None when cancelled.
#[tauri::command]
pub(crate) async fn backup_export_file(
webview: Webview,
app: AppHandle,
include_drafts: bool,
extra: Option<Map<String, Value>>,
) -> Result<Option<String>, String> {
shell_only(&webview)?;
let sections = collect(&app, include_drafts, extra)?;
let bytes = serde_json::to_vec_pretty(&envelope(&app, sections)).map_err(|_| "BACKUP: serialization failed")?;
let today = chrono::Utc::now().format("%Y-%m-%d");
let name = format!("wayBetter-settings-{today}.{}", backup::EXTENSION);
let picked = app
.dialog()
.file()
.set_parent(&webview.window())
.set_title(tr(&app, "native.dialog.saveBackup", "Save wayBetter settings"))
.set_file_name(&name)
.add_filter(tr(&app, "native.dialog.backupFilter", "wayBetter settings"), &[backup::EXTENSION])
.blocking_save_file();
let Some(picked) = picked else { return Ok(None) };
let mut path = picked.into_path().map_err(|_| "BACKUP: not a local file")?;
if path.extension().and_then(|ext| ext.to_str()) != Some(backup::EXTENSION) {
path.set_extension(backup::EXTENSION);
}
backup::write_atomic(&path, &bytes)?;
Ok(Some(path.to_string_lossy().into_owned()))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OpenedBackup {
file: String,
created_at: Option<String>,
app_version: Option<String>,
sections: Map<String, Value>,
}
/// Asks for a `.waybetter` file and returns its sections for the preview (nothing applied yet).
#[tauri::command]
pub(crate) async fn backup_open_file(webview: Webview, app: AppHandle) -> Result<Option<OpenedBackup>, String> {
shell_only(&webview)?;
let picked = app
.dialog()
.file()
.set_parent(&webview.window())
.set_title(tr(&app, "native.dialog.importBackup", "Import wayBetter settings"))
.add_filter(tr(&app, "native.dialog.backupFilter", "wayBetter settings"), &[backup::EXTENSION, "json"])
.blocking_pick_file();
let Some(picked) = picked else { return Ok(None) };
let path = picked.into_path().map_err(|_| "BACKUP: not a local file")?;
let meta = std::fs::metadata(&path).map_err(|_| "BACKUP: file not found")?;
if meta.len() as usize > backup::MAX_BACKUP_BYTES {
return Err("BACKUP: the file is larger than 32 MB".into());
}
let bytes = std::fs::read(&path).map_err(|error| format!("BACKUP: {error}"))?;
let sections = backup::parse_backup(&bytes, hooks(&app).backup_section_names())?;
let head: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
Ok(Some(OpenedBackup {
file: path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(),
created_at: head.get("createdAt").and_then(Value::as_str).map(str::to_owned),
app_version: head.get("appVersion").and_then(Value::as_str).map(str::to_owned),
sections,
}))
}
/// Applies the sections the owner chose (the shell applies `decks` itself): secret-looking keys
/// are removed first, then the app checks and applies each section
/// ([`crate::app::SiteHooks::backup_apply`]). Returns what was applied and notes for the owner.
#[tauri::command]
pub(crate) async fn backup_apply(webview: Webview, app: AppHandle, sections: Map<String, Value>) -> Result<Value, String> {
shell_only(&webview)?;
let mut sections = Value::Object(sections);
backup::strip_secrets(&mut sections);
let sections = sections.as_object().cloned().unwrap_or_default();
hooks(&app).backup_apply(&app, &sections)
}
/// The backup commands ([`crate::commands::BACKUP`]).
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![backup_collect, backup_export_file, backup_open_file, backup_apply]
}

View file

@ -113,9 +113,14 @@ pub const SYNC: &[&str] = &[
/// served when the app sets `AppConfig::dev`.
pub const DEV: &[&str] = &["dev_get", "dev_set", "dev_pick_theme", "dev_clear_theme", "dev_reload_plugin", "dev_report_error"];
/// Shell-only, optional: the settings backup (collect, export to a file, open a file for the preview,
/// apply chosen sections). State-free: served for every app; what a backup carries is the app's
/// (`SiteHooks::backup_sections`, `backup_drafts`, `backup_apply`).
pub const BACKUP: &[&str] = &["backup_collect", "backup_export_file", "backup_open_file", "backup_apply"];
/// 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)
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)
}
/// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`).
@ -130,8 +135,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());
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) {
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) {
if !all.contains(command) {
all.push(command);
}
@ -148,7 +153,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) {
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) {
assert!(all.contains(command), "{command}");
assert!(is_host(command));
}
@ -158,7 +163,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)));
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!(!TAB.iter().any(|command| SITE.contains(command)));
}
}

View file

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

View file

@ -259,6 +259,14 @@ pub fn assert_dev_guards() {
}
}
/// 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");
for command in commands::BACKUP {
assert_guarded(source, command, "shell_only(&webview)?");
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -271,6 +279,7 @@ mod tests {
assert_update_guards();
assert_sync_guards();
assert_dev_guards();
assert_backup_guards();
}
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {