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

369 lines
15 KiB
Rust

//! 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<Option<Key>>,
/// 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<tauri::State<'_, SyncState>, String> {
app.try_state::<SyncState>().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<WorkspaceStore, String> {
shell_only(&webview)?;
Ok(read_json::<WorkspaceStore>(&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<WorkspaceStore, String> {
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 `<folder>/wayBetter Sync`).
folder: Option<String>,
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<SyncLocal, String> {
let mut local = read_json::<SyncLocal>(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<PathBuf> {
local.folder.as_deref().map(|folder| sync::sync_dir(Path::new(folder)))
}
fn current_key(app: &AppHandle, local: &SyncLocal) -> Option<Key> {
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::<Value>(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<String>,
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<SnapshotInfo>,
error: Option<String>,
}
fn status(app: &AppHandle) -> Result<SyncStatus, String> {
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<Map<String, Value>>) -> Result<Map<String, Value>, 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<SyncStatus, String> {
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<SyncStatus, String> {
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<SyncStatus, String> {
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<SyncStatus, String> {
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<SyncStatus, String> {
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<SyncStatus, String> {
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<String>,
}
/// 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<Map<String, Value>>, force: bool) -> Result<PushResult, String> {
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<Map<String, Value>>) -> Result<Option<SnapshotInfo>, 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<Map<String, Value>, 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::<Value>(&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<SyncStatus, String> {
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<tauri::Wry>) -> 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
]
}