//! Saved workspaces and encrypted folder sync, native side ([`crate::commands::WORKSPACES`], //! [`crate::commands::SYNC`]). Every command is shell-only. The format, the crypto and the //! validation are [`crate::workspaces`]' and [`crate::sync`]'s (Argon2id + XChaCha20-Poly1305 //! snapshots of the settings backup in a folder the owner picks); this module keeps the local //! state (which folder, this device, the remembered key) and the dialogs. //! //! The synced settings are the app's own backup sections //! ([`crate::app::SiteHooks::backup_sections`]) plus the shell's own storage (`decks`, `shell`), //! never drafts, with secret-looking keys removed. The sync key lives in memory, or in app data //! `sync-key.json` when the owner ticks "remember on this computer"; it is never part of a backup //! or a snapshot. use crate::app::{hooks, tr}; use crate::backup; use crate::host::{data_file, read_json, shell_only, write_json}; use crate::sync::{self, Key, SnapshotInfo}; use crate::workspaces::{self, WorkspaceStore}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{AppHandle, Manager, Webview}; use tauri_plugin_dialog::DialogExt; const SYNC_FILE: &str = "sync.json"; /// The remembered sync key. Never part of a backup or a snapshot. const SYNC_KEY_FILE: &str = "sync-key.json"; /// The sync state (the unlocked key, and a lock that serializes pushes, pulls and unlocks). #[derive(Default)] pub(crate) struct SyncState { key: Mutex>, /// Serializes pushes, pulls and unlocks (file writes in the folder). busy: Mutex<()>, } /// Does nothing unless the app set [`crate::app::AppConfig::sync`]. pub(crate) fn init(app: &AppHandle) { if crate::app::config(app).sync { app.manage(SyncState::default()); } } fn sync_state(app: &AppHandle) -> Result, String> { app.try_state::().ok_or_else(|| "UNAVAILABLE: this app has no sync".to_owned()) } // --------------------------------------------------------------------------------------------- // Workspaces #[tauri::command] pub(crate) async fn workspaces_get(webview: Webview, app: AppHandle) -> Result { shell_only(&webview)?; Ok(read_json::(&app, workspaces::FILE).and_then(|store| store.validated().ok()).unwrap_or_default()) } #[tauri::command] pub(crate) async fn workspaces_set(webview: Webview, app: AppHandle, store: WorkspaceStore) -> Result { shell_only(&webview)?; let store = store.validated()?; write_json(&app, workspaces::FILE, &store)?; Ok(store) } // --------------------------------------------------------------------------------------------- // Sync #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] struct SyncLocal { /// The folder the owner picked (the snapshots live in `/wayBetter Sync`). folder: Option, device: String, device_name: String, /// Push automatically when settings change. auto_push: bool, /// Newest snapshot of another device that was pulled (or found equal to ours), ms. last_pulled: u64, /// sha256 of the settings last pushed (skip unchanged pushes). last_pushed_hash: String, last_pushed_at: u64, } fn load_sync(app: &AppHandle) -> Result { let mut local = read_json::(app, SYNC_FILE).unwrap_or_default(); if !sync::valid_device(&local.device) { local.device = sync::new_device_id()?; local.device_name = std::env::var("COMPUTERNAME").or_else(|_| std::env::var("HOSTNAME")).unwrap_or_else(|_| "This computer".into()); local.auto_push = true; write_json(app, SYNC_FILE, &local)?; } Ok(local) } fn sync_folder(local: &SyncLocal) -> Option { local.folder.as_deref().map(|folder| sync::sync_dir(Path::new(folder))) } fn current_key(app: &AppHandle, local: &SyncLocal) -> Option { let state = sync_state(app).ok()?; let mut key = state.key.lock().ok()?; if key.is_none() { // A remembered key, if it still opens this folder. let remembered = read_json::(app, SYNC_KEY_FILE).and_then(|value| value.get("key").and_then(Value::as_str).and_then(Key::from_base64)); if let (Some(remembered), Some(dir)) = (remembered, sync_folder(local)) { if sync::key_matches(&dir, &remembered) { *key = Some(remembered); } } } key.clone() } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SyncStatus { folder: Option, device: String, device_name: String, auto_push: bool, /// The folder already holds a sync setup (unlock with its passphrase) or not (choose one). initialized: bool, unlocked: bool, remembered: bool, last_pushed_at: u64, last_pulled: u64, snapshots: Vec, error: Option, } fn status(app: &AppHandle) -> Result { let local = load_sync(app)?; let dir = sync_folder(&local); let (initialized, error) = match &dir { Some(dir) => match sync::read_meta(dir) { Ok(meta) => (meta.is_some(), None), Err(error) => (false, Some(error)), }, None => (false, None), }; let unlocked = dir.is_some() && current_key(app, &local).is_some(); Ok(SyncStatus { folder: local.folder.clone(), device: local.device.clone(), device_name: local.device_name.clone(), auto_push: local.auto_push, initialized, unlocked, remembered: data_file(app, SYNC_KEY_FILE).is_some_and(|path| path.exists()), last_pushed_at: local.last_pushed_at, last_pulled: local.last_pulled, snapshots: dir.map(|dir| sync::list(&dir).into_iter().take(20).collect()).unwrap_or_default(), error, }) } fn forget_key(app: &AppHandle) { if let Ok(state) = sync_state(app) { if let Ok(mut key) = state.key.lock() { *key = None; } } if let Some(path) = data_file(app, SYNC_KEY_FILE) { let _ = std::fs::remove_file(path); } } /// The app's backup sections plus the shell's own storage (`decks`, `shell`), never drafts, /// secret-looking keys removed: what sync carries. fn collect(app: &AppHandle, extra: Option>) -> Result, String> { let mut sections = hooks(app).backup_sections(app)?; 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 hash(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect() } #[tauri::command] pub(crate) async fn sync_status(webview: Webview, app: AppHandle) -> Result { shell_only(&webview)?; status(&app) } /// Picks the folder to sync through (e.g. a OneDrive/iCloud/Dropbox folder). Locks until unlocked. #[tauri::command] pub(crate) async fn sync_choose_folder(webview: Webview, app: AppHandle) -> Result { shell_only(&webview)?; let title = tr(&app, "native.dialog.syncFolder", "Choose a folder to sync wayBetter settings through"); let picked = app.dialog().file().set_parent(&webview.window()).set_title(title).blocking_pick_folder(); let Some(picked) = picked else { return status(&app) }; let path = picked.into_path().map_err(|_| "SYNC: not a local folder")?; let mut local = load_sync(&app)?; local.folder = Some(path.to_string_lossy().into_owned()); local.last_pulled = 0; local.last_pushed_hash.clear(); write_json(&app, SYNC_FILE, &local)?; forget_key(&app); status(&app) } /// Unlocks the folder with the passphrase (or sets the folder up with it the first time). #[tauri::command] pub(crate) async fn sync_unlock(webview: Webview, app: AppHandle, passphrase: String, remember: bool) -> Result { shell_only(&webview)?; let local = load_sync(&app)?; let dir = sync_folder(&local).ok_or("SYNC: choose a folder first")?; // Argon2id takes a moment on purpose; keep it off the async runtime's threads. let task_app = app.clone(); let key = tauri::async_runtime::spawn_blocking(move || { let state = sync_state(&task_app)?; let _busy = state.busy.lock().map_err(|_| "SYNC: busy".to_string())?; sync::unlock_or_create(&dir, &passphrase, sync::KdfParams::default()) }) .await .map_err(|_| "SYNC: unlock failed")?? .0; if remember { write_json(&app, SYNC_KEY_FILE, &json!({ "key": key.to_base64() }))?; #[cfg(unix)] if let Some(path) = data_file(&app, SYNC_KEY_FILE) { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); } } else if let Some(path) = data_file(&app, SYNC_KEY_FILE) { let _ = std::fs::remove_file(path); } *sync_state(&app)?.key.lock().map_err(|_| "SYNC: busy")? = Some(key); status(&app) } #[tauri::command] pub(crate) async fn sync_lock(webview: Webview, app: AppHandle) -> Result { shell_only(&webview)?; forget_key(&app); status(&app) } /// Stops syncing on this computer (the folder and its snapshots stay). #[tauri::command] pub(crate) async fn sync_disable(webview: Webview, app: AppHandle) -> Result { shell_only(&webview)?; let mut local = load_sync(&app)?; local.folder = None; write_json(&app, SYNC_FILE, &local)?; forget_key(&app); status(&app) } #[tauri::command] pub(crate) async fn sync_configure(webview: Webview, app: AppHandle, auto_push: bool, device_name: String) -> Result { shell_only(&webview)?; let mut local = load_sync(&app)?; local.auto_push = auto_push; let name: String = device_name.chars().filter(|ch| !ch.is_control()).take(40).collect(); if !name.trim().is_empty() { local.device_name = name.trim().to_owned(); } write_json(&app, SYNC_FILE, &local)?; status(&app) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PushResult { pushed: bool, name: Option, } /// Writes an encrypted snapshot when the settings changed since the last push. `force` writes one /// even when nothing changed (the "Sync now" button). `extra`: the shell's own sections (decks). #[tauri::command] pub(crate) async fn sync_push(webview: Webview, app: AppHandle, extra: Option>, force: bool) -> Result { shell_only(&webview)?; let mut local = load_sync(&app)?; let dir = sync_folder(&local).ok_or("SYNC: choose a folder first")?; if !force && !local.auto_push { return Ok(PushResult { pushed: false, name: None }); } let key = current_key(&app, &local).ok_or("SYNC: locked; enter the passphrase")?; let plaintext = sync::settings_plaintext(&collect(&app, extra)?); let digest = hash(&plaintext); if !force && digest == local.last_pushed_hash { return Ok(PushResult { pushed: false, name: None }); } let state = sync_state(&app)?; let _busy = state.busy.lock().map_err(|_| "SYNC: busy")?; let now = chrono::Utc::now().timestamp_millis().max(0) as u64; let name = sync::write_snapshot(&dir, &key, &local.device, &local.device_name, now, &plaintext)?; local.last_pushed_hash = digest; local.last_pushed_at = now; write_json(&app, SYNC_FILE, &local)?; Ok(PushResult { pushed: true, name: Some(name) }) } /// The newest snapshot another computer wrote since the last pull, if its settings differ from /// ours (one that equals ours is marked as seen). None when locked or nothing is newer. #[tauri::command] pub(crate) async fn sync_check(webview: Webview, app: AppHandle, extra: Option>) -> Result, String> { shell_only(&webview)?; let mut local = load_sync(&app)?; let Some(dir) = sync_folder(&local) else { return Ok(None) }; let Some(key) = current_key(&app, &local) else { return Ok(None) }; let Some(newer) = sync::newer_from_others(&dir, &local.device, local.last_pulled) else { return Ok(None) }; let (_, plaintext) = sync::read_snapshot(&dir, &key, &newer.name)?; let ours = sync::settings_plaintext(&collect(&app, extra)?); let theirs: Value = serde_json::from_slice(&plaintext).map_err(|_| "SYNC: damaged snapshot")?; if serde_json::to_vec(&theirs).unwrap_or_default() == ours { local.last_pulled = newer.created_at; write_json(&app, SYNC_FILE, &local)?; return Ok(None); } Ok(Some(newer)) } /// Opens snapshot `name` for the import preview (nothing applied yet). #[tauri::command] pub(crate) async fn sync_open(webview: Webview, app: AppHandle, name: String) -> Result, String> { shell_only(&webview)?; let local = load_sync(&app)?; let dir = sync_folder(&local).ok_or("SYNC: choose a folder first")?; let key = current_key(&app, &local).ok_or("SYNC: locked; enter the passphrase")?; let (_, plaintext) = sync::read_snapshot(&dir, &key, &name)?; let wrapped = json!({ "format": backup::FORMAT, "version": backup::VERSION, "sections": serde_json::from_slice::(&plaintext).map_err(|_| "SYNC: damaged snapshot")? }); backup::parse_backup(&serde_json::to_vec(&wrapped).map_err(|_| "SYNC: damaged snapshot")?, hooks(&app).backup_section_names()) } /// Records that snapshot `name` was pulled (or skipped): it is not offered again. #[tauri::command] pub(crate) async fn sync_mark_pulled(webview: Webview, app: AppHandle, name: String) -> Result { shell_only(&webview)?; let mut local = load_sync(&app)?; let created = sync_folder(&local).and_then(|dir| sync::list(&dir).into_iter().find(|info| info.name == name)).map(|info| info.created_at); if let Some(created) = created { local.last_pulled = local.last_pulled.max(created); write_json(&app, SYNC_FILE, &local)?; } status(&app) } /// The workspaces and sync commands ([`crate::commands::WORKSPACES`], [`crate::commands::SYNC`]). pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ workspaces_get, workspaces_set, sync_status, sync_choose_folder, sync_unlock, sync_lock, sync_disable, sync_configure, sync_push, sync_check, sync_open, sync_mark_pulled ] }