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:
CRTkafa 2026-10-01 04:26:03 +07:00
parent 385ca189da
commit 48ff509b15
5 changed files with 435 additions and 5 deletions

View file

@ -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 {

View file

@ -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)));
}
}

View file

@ -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"))]

View 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
]
}

View file

@ -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 {