waybetter/crates/waybetter-host/src/backup_commands.rs

147 lines
6.7 KiB
Rust

//! 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]
}