//! 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>) -> Result, 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) -> 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, 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>, ) -> Result, 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, app_version: Option, sections: Map, } /// 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, 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) -> Result { 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) -> bool + Send + Sync + 'static { tauri::generate_handler![backup_collect, backup_export_file, backup_open_file, backup_apply] }