Host: workspaces (always) and encrypted folder sync (AppConfig::with_sync) commands; SiteHooks::backup_sections and backup_section_names say what a backup and sync carry; commands::WORKSPACES, commands::SYNC, testkit::assert_sync_guards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
385ca189da
commit
48ff509b15
5 changed files with 435 additions and 5 deletions
|
|
@ -89,6 +89,9 @@ pub struct AppConfig {
|
|||
/// Google Fonts (install, remove, preview; the cache in app data): serves [`commands::GFONTS`]
|
||||
/// and calls [`SiteHooks::fonts_changed`]. Needs the `net` feature.
|
||||
pub fonts: bool,
|
||||
/// Encrypted folder sync of the settings backup: serves [`commands::SYNC`] and needs
|
||||
/// [`SiteHooks::backup_sections`]. ([`commands::WORKSPACES`] is served for every app.)
|
||||
pub sync: bool,
|
||||
/// Native updates and the hotfix channel: serves [`commands::UPDATE`] and runs the update loop.
|
||||
/// Needs `updater` (the plugin) and the `net` feature.
|
||||
#[cfg(feature = "net")]
|
||||
|
|
@ -117,6 +120,7 @@ impl AppConfig {
|
|||
store: None,
|
||||
media: false,
|
||||
fonts: false,
|
||||
sync: false,
|
||||
#[cfg(feature = "net")]
|
||||
updates: None,
|
||||
}
|
||||
|
|
@ -129,6 +133,12 @@ impl AppConfig {
|
|||
self
|
||||
}
|
||||
|
||||
/// Serves the sync commands ([`commands::SYNC`]).
|
||||
pub const fn with_sync(mut self) -> Self {
|
||||
self.sync = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Serves the Google Fonts commands ([`commands::GFONTS`]).
|
||||
pub const fn with_fonts(mut self) -> Self {
|
||||
self.fonts = true;
|
||||
|
|
@ -229,6 +239,18 @@ pub trait SiteHooks: Send + Sync + 'static {
|
|||
/// theme, and the font bytes, again.
|
||||
fn fonts_changed(&self, _app: &AppHandle) {}
|
||||
|
||||
/// The app's native settings as backup sections (themes, plugins, tweaks, ... keyed by section
|
||||
/// name, from app data): what a backup and [`commands::SYNC`] carry besides the shell's own
|
||||
/// storage. By default none.
|
||||
fn backup_sections(&self, _app: &AppHandle) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
||||
Ok(serde_json::Map::new())
|
||||
}
|
||||
|
||||
/// The names of the sections an import may carry (others are dropped). By default none.
|
||||
fn backup_section_names(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// `tab` was laid out for `item` (existing or new), before it is placed.
|
||||
fn tab_placed(&self, _app: &AppHandle, _tab: &Webview, _item: &TabLayout) {}
|
||||
|
||||
|
|
@ -1147,6 +1169,11 @@ fn store_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + '
|
|||
|_| false
|
||||
}
|
||||
|
||||
/// The workspaces and sync commands ([`commands::WORKSPACES`], [`commands::SYNC`]).
|
||||
fn sync_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
||||
crate::sync_commands::handler()
|
||||
}
|
||||
|
||||
/// The update commands ([`commands::UPDATE`]): with the `net` feature only.
|
||||
#[cfg(feature = "net")]
|
||||
fn update_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 'static {
|
||||
|
|
@ -1208,6 +1235,7 @@ impl App {
|
|||
let media_handler = media_handler();
|
||||
let fonts_handler = fonts_handler();
|
||||
let update_handler = update_handler();
|
||||
let sync_handler = sync_handler();
|
||||
let builder = tauri::Builder::<Wry>::default()
|
||||
// Must be first: a second launch focuses the running window instead of fighting over
|
||||
// the app's data and WebView profile (and hands over its link).
|
||||
|
|
@ -1288,6 +1316,7 @@ impl App {
|
|||
#[cfg(feature = "net")]
|
||||
crate::store_commands::init(&handle, &config);
|
||||
crate::media_commands::init(&handle);
|
||||
crate::sync_commands::init(&handle);
|
||||
#[cfg(feature = "net")]
|
||||
crate::fonts_commands::init(&handle);
|
||||
#[cfg(feature = "net")]
|
||||
|
|
@ -1314,6 +1343,8 @@ impl App {
|
|||
fonts_handler(invoke)
|
||||
} else if commands::UPDATE.contains(&invoke.message.command()) {
|
||||
update_handler(invoke)
|
||||
} else if commands::WORKSPACES.contains(&invoke.message.command()) || commands::SYNC.contains(&invoke.message.command()) {
|
||||
sync_handler(invoke)
|
||||
} else if commands::is_host(invoke.message.command()) {
|
||||
host_handler(invoke)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -90,9 +90,28 @@ pub const GFONTS: &[&str] = &["gfonts_installed", "gfonts_install", "gfonts_remo
|
|||
/// state), served when the app sets `AppConfig::updates`; needs the `net` feature.
|
||||
pub const UPDATE: &[&str] = &["update_status", "update_check", "apply_update", "app_ready"];
|
||||
|
||||
/// Shell-only, optional: saved workspaces (named tab, pane and deck layouts). State-free: served
|
||||
/// for every app; an app that uses them grants them to the shell.
|
||||
pub const WORKSPACES: &[&str] = &["workspaces_get", "workspaces_set"];
|
||||
|
||||
/// Shell-only, optional: encrypted folder sync of the settings backup, served when the app sets
|
||||
/// `AppConfig::sync`.
|
||||
pub const SYNC: &[&str] = &[
|
||||
"sync_status",
|
||||
"sync_choose_folder",
|
||||
"sync_unlock",
|
||||
"sync_lock",
|
||||
"sync_disable",
|
||||
"sync_configure",
|
||||
"sync_push",
|
||||
"sync_check",
|
||||
"sync_open",
|
||||
"sync_mark_pulled",
|
||||
];
|
||||
|
||||
/// Whether `command` is one of the host's (dispatched to the host, never to the app's handler).
|
||||
pub fn is_host(command: &str) -> bool {
|
||||
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command) || MEDIA.contains(&command) || GFONTS.contains(&command) || UPDATE.contains(&command)
|
||||
SHELL.contains(&command) || TAB.contains(&command) || SITE.contains(&command) || STORE.contains(&command) || MEDIA.contains(&command) || GFONTS.contains(&command) || UPDATE.contains(&command) || WORKSPACES.contains(&command) || SYNC.contains(&command)
|
||||
}
|
||||
|
||||
/// The capability permission tauri-build generates for `command` (`tabs_sync` → `allow-tabs-sync`).
|
||||
|
|
@ -107,8 +126,8 @@ pub fn permission(command: &str) -> String {
|
|||
/// tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&["draft_list"]))
|
||||
/// ```
|
||||
pub fn manifest(app: &[&'static str]) -> &'static [&'static str] {
|
||||
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len() + MEDIA.len() + GFONTS.len() + UPDATE.len());
|
||||
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE) {
|
||||
let mut all: Vec<&'static str> = Vec::with_capacity(app.len() + SHELL.len() + TAB.len() + SITE.len() + STORE.len() + MEDIA.len() + GFONTS.len() + UPDATE.len() + WORKSPACES.len() + SYNC.len());
|
||||
for command in app.iter().chain(SHELL).chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC) {
|
||||
if !all.contains(command) {
|
||||
all.push(command);
|
||||
}
|
||||
|
|
@ -125,7 +144,7 @@ mod tests {
|
|||
let all = manifest(&["draft_list", "tabs_sync"]);
|
||||
assert_eq!(all[0], "draft_list");
|
||||
assert_eq!(all.iter().filter(|c| **c == "tabs_sync").count(), 1);
|
||||
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE) {
|
||||
for command in SHELL.iter().chain(TAB).chain(SITE).chain(STORE).chain(MEDIA).chain(GFONTS).chain(UPDATE).chain(WORKSPACES).chain(SYNC) {
|
||||
assert!(all.contains(command), "{command}");
|
||||
assert!(is_host(command));
|
||||
}
|
||||
|
|
@ -135,7 +154,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn shell_and_tab_commands_do_not_overlap() {
|
||||
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command) || MEDIA.contains(command) || GFONTS.contains(command) || UPDATE.contains(command)));
|
||||
assert!(!SHELL.iter().any(|command| TAB.contains(command) || SITE.contains(command) || STORE.contains(command) || MEDIA.contains(command) || GFONTS.contains(command) || UPDATE.contains(command) || WORKSPACES.contains(command) || SYNC.contains(command)));
|
||||
assert!(!TAB.iter().any(|command| SITE.contains(command)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ pub mod app;
|
|||
mod store_commands;
|
||||
#[cfg(feature = "app")]
|
||||
pub mod media_commands;
|
||||
#[cfg(feature = "app")]
|
||||
mod sync_commands;
|
||||
#[cfg(all(feature = "app", feature = "net"))]
|
||||
mod fonts_commands;
|
||||
#[cfg(all(feature = "app", feature = "net"))]
|
||||
|
|
|
|||
369
crates/waybetter-host/src/sync_commands.rs
Normal file
369
crates/waybetter-host/src/sync_commands.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
//! 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
|
||||
]
|
||||
}
|
||||
|
|
@ -243,6 +243,14 @@ pub fn assert_update_guards() {
|
|||
}
|
||||
}
|
||||
|
||||
/// The workspaces and sync commands' wrappers check their caller first. Runs against the host's source.
|
||||
pub fn assert_sync_guards() {
|
||||
let source = include_str!("sync_commands.rs");
|
||||
for command in commands::WORKSPACES.iter().chain(commands::SYNC) {
|
||||
assert_guarded(source, command, "shell_only(&webview)?");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -253,6 +261,7 @@ mod tests {
|
|||
assert_media_guards();
|
||||
assert_fonts_guards();
|
||||
assert_update_guards();
|
||||
assert_sync_guards();
|
||||
}
|
||||
|
||||
fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue