369 lines
15 KiB
Rust
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
|
|
]
|
|
}
|