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:
parent
198b1160f2
commit
409949edb9
5 changed files with 188 additions and 5 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
147
crates/waybetter-host/src/backup_commands.rs
Normal file
147
crates/waybetter-host/src/backup_commands.rs
Normal 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, §ions)
|
||||
}
|
||||
|
||||
/// 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]
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue