147 lines
6.7 KiB
Rust
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, §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]
|
|
}
|