| title | live_sync Module — Real-Time Sync Engine | ||
|---|---|---|---|
| created | 2026-05-28 | ||
| updated | 2026-05-28 | ||
| type | module | ||
| tags |
|
||
| sources |
|
Status: Experimental — 2-way live file synchronization between a local folder and Proton Drive.
Source:
src-tauri/src/live_sync.rs(944 lines, integrates withsync_db.rsfor persistent metadata)Broader architecture: See sync-system.md for the full live sync architecture overview, including Tauri command wiring, security origin validation, and lifecycle flows.
live_sync.rs is the core Rust engine for real-time bidirectional file sync. It
operates three independent threads:
| Thread | Name | Trigger | Mechanism |
|---|---|---|---|
| Watcher | live-sync-watcher |
OS-level filesystem events | notify crate (inotify/FSEvents) → mpsc channel → Tauri event emit |
| Poller | live-sync-poller |
Timer (default 30s) | Full recursive scan → snapshot diff → Tauri event emit |
| Remote Apply | (Tauri command thread) | Frontend calls handle_remote_update |
Validates, writes/deletes file on disk |
The dual detection (watcher + poller) provides near-instant event reporting with guaranteed coverage: the watcher catches changes in milliseconds, and the poller fills any gaps from inotify queue overflows, atomic renames, or missed events.
Every detected local change is persisted to the sync database (sync_db.rs)
as a LocalPending item before the event is emitted to the frontend. This means
sync state survives both stop()/start() cycles and can be queried for pending
items after restart.
The central stateful manager. All mutable state is behind std::sync::Mutex:
| Field | Type | Purpose |
|---|---|---|
watcher |
Mutex<Option<RecommendedWatcher>> |
Active notify filesystem watcher handle |
folder |
Mutex<Option<PathBuf>> |
User-selected sync root directory path |
root_canonical |
Mutex<Option<PathBuf>> |
Canonicalized root (for path traversal checks) |
worker |
Mutex<Option<JoinHandle<()>>> |
Watcher background thread handle |
poller |
Mutex<Option<JoinHandle<()>>> |
Poller background thread handle |
poll_stop |
Mutex<Option<mpsc::Sender<()>>> |
Channel to signal poller to stop |
known_files |
Arc<Mutex<HashMap<PathBuf, Instant>>> |
Shared suppression cache (anti-echo) |
Lifecycle: Default::default() → start(app, folder) → (running) → stop().
Only one sync root at a time — calling start() again stops the previous one and
starts fresh.
Serialized as JSON and emitted on the "live-sync://local-change" Tauri event
channel. The frontend sync engine depends on this exact channel name — changing it
would break the sync contract.
pub struct LiveSyncEvent {
pub kind: String, // "create" | "modify" | "remove"
pub paths: Vec<String>, // Absolute paths (backward compat)
pub root_path: String, // Sync root path
pub relative_paths: Vec<String>, // Root-relative paths (for path mapping)
pub source: String, // "watcher" or "poller"
}Returned to the frontend for UI binding (sync toggle, folder display, interval display).
pub struct LiveSyncStatus {
pub enabled: bool,
pub folder_path: Option<String>,
pub poll_interval_seconds: u64, // defaults to 30
}Deserialized from the frontend when it pushes a remote change received from Proton Drive.
Uses #[serde(rename_all = "camelCase")] so the frontend sends relativePath,
contentBase64, etc.
pub struct RemoteSyncChange {
pub relative_path: String, // e.g. "Documents/report.pdf"
pub action: String, // "create" | "update" | "delete"
pub content_base64: Option<String>, // Base64 content; required for create/update
}Uses the notify crate's RecommendedWatcher with RecursiveMode::Recursive:
let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
watcher.watch(&folder, RecursiveMode::Recursive)?;The worker thread (live-sync-watcher) blocks on rx and processes each event:
- Maps
EventKind::Create(_)→"create",Modify(_)→"modify",Remove(_)→"remove" - Filters paths through
should_ignore_known_file()(anti-echo suppression) - Calls
emit_local_change()which writes to the sync DB then emits the Tauri event
Acts as a safety net for events the watcher might miss:
- Takes an initial baseline snapshot (
scan_sync_root()) - Blocks on
poll_stop_rx.recv_timeout(poll_interval)— this serves as both the stop signal and the timer - When the timeout fires (no stop signal received), takes a new snapshot
- Calls
diff_snapshots(previous, next)to find creates, modifies, and removes - Filters through the suppression cache
- Calls
emit_local_change()for each change - Replaces the baseline with the new snapshot and loops
let poller = std::thread::Builder::new()
.name("live-sync-poller")
.spawn(move || loop {
if poll_stop_rx.recv_timeout(poll_interval).is_ok() { break; }
scan_sync_root(&poller_root)
.and_then(|next| diff_snapshots(&snapshot, &next))
.map(|changes| emit_filtered_changes(...));
snapshot = next_snapshot;
})?;struct FileFingerprint {
len: u64, // File size in bytes
modified: Option<u128>, // mtime in nanoseconds since epoch
}
type SyncSnapshot = HashMap<PathBuf, FileFingerprint>;The recursive scan walks the entire sync root tree, skipping symlinks, and records
(size, mtime_ns) for every file. The diff compares two snapshots using set
operations on the path keys:
- Creates: paths in
nextbut not inprevious - Modifies: paths in both but fingerprint changed
- Removes: paths in
previousbut not innext
When a remote change is applied to the local filesystem, the local watcher immediately detects the write. Without suppression, this would trigger an upload back to Proton, creating an infinite loop.
| Property | Value |
|---|---|
| Storage | HashMap<PathBuf, Instant> behind Arc<Mutex<...>> |
| TTL | 30 seconds (SUPPRESSION_TTL) |
| Max entries | 4096 (SUPPRESSION_CACHE_MAX) |
| Pruning | Time-based on every access; capacity-based overflow evicts oldest |
| Consumed on read | cache.remove(path) — one suppression per mark |
apply_remote_change()
→ mark_known_file(path) ← inserts path + Instant::now()
→ fs::write / fs::remove_file
→ notify watcher fires (same path)
→ should_ignore_known_file(path)
→ If found AND age ≤ 30s → SUPPRESS (remove entry, return true)
→ Otherwise → return false → emit event
The entry is consumed on first lookup (cache.remove()), not on a timer. This
means one remote write suppresses exactly one detection event. If the file watcher
fires multiple times (e.g., create + modify events for the same write), only the
first is suppressed — but in practice these arrive in the same batch and the
suppression covers both since the entry has already been consumed.
Every local change is recorded in the sync database via record_local_change_metadata():
fn record_local_change_metadata(
db_path: &Path, root_id: &str, root: &Path, kind: &str, paths: &[String]
) -> Result<(), String> {
for path in paths {
if kind == "remove" {
db.mark_tombstone(root_id, &relative)?; // soft-delete, preserves remote ID
} else {
let metadata = fs::symlink_metadata(path).ok();
db.upsert_local_item(root_id, &relative, local_kind, local_size,
local_mtime_ns, None, SyncItemState::LocalPending)?;
}
}
}This is what connects the live sync engine to the persistent metadata layer. Without it, changes would be ephemeral — the frontend would receive events but there'd be no record of what needs uploading.
The frontend calls handle_remote_update (a Tauri command) when Proton pushes a
file change:
Frontend receives remote change from Proton
→ invoke('handle_remote_update', { change: RemoteSyncChange })
→ ensure_sync_command_allowed() (origin check: must be tauri://localhost)
→ apply_remote_change(change)
→ Reject "create"/"update" without content_base64
→ Validate relative_path: no "..", "/", or Prefix components
→ Validate target within canonical root (symlink traversal check)
→ For create/update: fs::create_dir_all(parent) → mark_known_file → fs::write
→ For delete: mark_known_file → fs::remove_file (if exists)
validate_path_within_root provides three-layer protection:
- Component-level symlink check — walks each path component and rejects any symlink
- Canonicalization — resolves the target (or nearest ancestor) and checks it starts with the sync root
- Non-existent path handling — when the target doesn't exist (pending remote create), walks ancestors to find one for canonical comparison
pub fn validate_path_within_root(root_canonical: &Path, target: &Path) -> Result<(), String> {
let mut cur = PathBuf::new();
for component in target.components() {
cur.push(component.as_os_str());
if let Ok(meta) = fs::symlink_metadata(&cur) {
if meta.file_type().is_symlink() {
return Err("symlink traversal is not allowed".into());
}
}
}
// ... canonicalization check for root escape ...
}All sync commands (start_sync, stop_sync, get_sync_status,
handle_remote_update, read_sync_file, set_sync_root) are gated behind an
origin check in main.rs:
fn ensure_sync_command_allowed(window: &tauri::WebviewWindow) -> Result<(), String> {
let current_url = window.url().map_err(|e| {
eprintln!("[Sync] failed to read window URL: {e}");
ERR_SYNC_NOT_ALLOWED.to_string()
})?;
let host = current_url.host_str().unwrap_or_default();
let is_allowed =
current_url.scheme() == "tauri" && (host == "localhost" || host == "tauri.localhost");
if !is_allowed {
eprintln!(
"[Sync] rejected command from origin scheme={} host={}",
current_url.scheme(),
host
);
return Err(ERR_SYNC_NOT_ALLOWED.to_string());
}
Ok(())
}This prevents the CAPTCHA verification page (verify.proton.me) from invoking sync
commands while the user is mid-authentication.
All user-facing error strings are compile-time constants with [LiveSync] log prefixes:
| Constant | Message | Triggers |
|---|---|---|
ERR_SYNC_SETUP_FAILED |
"Failed to start live sync" | Watcher init, path canonicalization, thread spawn |
ERR_SYNC_STATE_UNAVAILABLE |
"Live sync is temporarily unavailable" | Any mutex lock failure |
ERR_SYNC_NOT_ACTIVE |
"Live sync is not active" | Remote change while sync not running |
ERR_SYNC_INVALID_REMOTE_CONTENT |
"Invalid remote file content" | Base64 decode failure |
ERR_SYNC_WRITE_FAILED |
"Failed to apply remote file update" | fs::write or fs::create_dir_all failure |
ERR_SYNC_DELETE_FAILED |
"Failed to apply remote file deletion" | fs::remove_file failure |
ERR_SYNC_INVALID_TARGET |
"Invalid sync target path" | Path traversal or symlink in path |
All audit-relevant events (remote write/delete accepted or rejected) are logged with
[LiveSync][AUDIT] prefix.
Hardcoded at the top of live_sync.rs:
| Constant | Value | Purpose |
|---|---|---|
SUPPRESSION_TTL |
Duration::from_secs(30) |
Suppression window after remote write |
SUPPRESSION_CACHE_MAX |
4096 |
Maximum entries before oldest evicted |
DEFAULT_SYNC_POLL_INTERVAL |
Duration::from_secs(30) |
Poller check interval |
No runtime configuration is exposed — these are compile-time constants.
Symptoms: Console shows [Sync] active enabled=true but no poll cycles appear. Files never sync.
Causes:
- Watcher thread panicked during event processing and the
JoinHandlewas dropped - Sync root path validation failed (path doesn't exist, is a symlink to nowhere, or is on a network mount)
- Disk is full — inotify can't create watches
Fix:
- Check the console for
[Sync]prefixed errors - Verify the sync root exists:
ls -d ~/ProtonDrive/ - Check disk space:
df -h ~/ProtonDrive/ - Check inotify watch limit (Linux):
cat /proc/sys/fs/inotify/max_user_watches— if you have many files, increase it:echo 524288 | sudo tee /proc/sys/fs/inotify/max_user_watches
Symptoms: Files added via the Proton Drive web app never appear locally.
Causes:
- Poller interval hasn't elapsed yet (30s default)
- Network connectivity to
drive-api.proton.meis broken - The remote scope changed (e.g. file moved from "My Files" to "Computers")
Fix:
- Wait 30+ seconds for the next poll cycle
- Check network:
curl -I https://drive-api.proton.me - Verify the file is in the same scope your sync root tracks (check the Proton web UI)
Symptoms: You modify a file locally, but it doesn't upload. Console shows no ChangeDetected event.
Causes:
- The suppression cache still holds the file's entry from the last remote download (keyed by path, not content hash)
- The file was downloaded <30s ago and the suppression window hasn't expired
- The file content hasn't actually changed (same mtime and size)
Fix:
- Wait 30s for the suppression cache entry to expire
- Force a change:
touchthe file and append a byte:echo " " >> file.txt - Restart the app to flush the suppression cache
Symptoms: The same file syncs repeatedly. Console shows repeated RemoteChange for the same file.
Causes:
- The file's modification time is in the future (Proton API returns future timestamps)
- The FileFingerprint comparison (len + mtime) is non-deterministic due to concurrent writes
- The file is being modified by an external process at the same time the poller reads it
Fix:
- Check file timestamp:
stat file.txt— if it's in the future, fix withtouch file.txt - Exclude the file from the sync root if it's being modified by another process (logs, caches, etc.)
- Sync System — Full sync architecture: Tauri command wiring, lifecycle flows, startup path, device name resolution
- Sync Database — SQLite schema, item states, privacy hashing, migration strategy
- Sync DB Module — The
sync_db.rsintegration: AppState wiring, SyncKeyring decryption, debounce/persistence constants - WebView Integration — How the frontend connects to sync commands, origin gating
- No conflict resolution — last-write-wins, remote always overrides local when both change
- No retry queue — transient write/decode failures are returned to the frontend immediately
- No file exclusion patterns — entire folder is watched recursively
- No initial state reconciliation at start — sync only tracks changes after
start(). The persistent DB records changes but the engine does not yet compare local files against remote on startup. - Blocking Mutex —
std::sync::Mutexcan block the async Tauri runtime under contention - Single sync root — only one folder at a time
- Unidirectional local events — Rust emits events and records metadata; the frontend is responsible for upload
- File size limit for sync bridge — Files over 100 MB (
MAX_SYNC_BRIDGE_FILE_BYTES) are rejected byread_sync_file(the Tauri command that reads local files for upload)