From efb141e555acfd5b90ab233069b6dd1fdd1d318f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 23:40:27 -0400 Subject: [PATCH] =?UTF-8?q?desktop:=20syncs=20on=20its=20own=20=E2=80=94?= =?UTF-8?q?=20at=20launch,=20soon=20after=20an=20edit,=20every=20few=20min?= =?UTF-8?q?utes=20and=20on=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the only caller of the sync engine was the "Sync now" button. A worker thread now owns every cycle (the button's included, so two never overlap): - launch: one cycle as the app opens; - edit: every 10s it reads a fingerprint of the pending set and sends when that moved. A fingerprint rather than "anything pending", because a rejected change stays pending and would otherwise be resent every tick forever; - timer: a pull every 5 minutes with nothing to send; - focus: at most once per 30s. Failed automatic cycles back off (doubling from 10s to 5 minutes). A panicking cycle counts as a failed one rather than ending the thread. Every cycle is emitted as inkwell://synced: the board reloads when the pull changed something, and the Sync screen shows the last automatic failure. No final push on quit; the launch cycle sends whatever was left. Co-Authored-By: Claude Opus 5.5 --- core/src/sync/engine.rs | 2 +- core/src/sync/push.rs | 50 ++++ desktop/src-tauri/src/autosync.rs | 393 +++++++++++++++++++++++++ desktop/src-tauri/src/commands/sync.rs | 34 +-- desktop/src-tauri/src/lib.rs | 17 ++ frontend/src/desktop/bridge.ts | 34 +++ frontend/src/views/BoardView.vue | 12 +- frontend/src/views/SyncView.vue | 87 ++++-- 8 files changed, 586 insertions(+), 43 deletions(-) create mode 100644 desktop/src-tauri/src/autosync.rs diff --git a/core/src/sync/engine.rs b/core/src/sync/engine.rs index 11a97e3..5204a2b 100644 --- a/core/src/sync/engine.rs +++ b/core/src/sync/engine.rs @@ -13,7 +13,7 @@ use super::push; use super::state; use crate::local::Db; -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct SyncOutcome { pub push: push::PushSummary, pub pull: pull::PullSummary, diff --git a/core/src/sync/push.rs b/core/src/sync/push.rs index 535d18d..1fc96cf 100644 --- a/core/src/sync/push.rs +++ b/core/src/sync/push.rs @@ -422,6 +422,25 @@ pub fn has_pending(conn: &Connection) -> rusqlite::Result { Ok(pending.is_some()) } +/// A value that changes whenever the set of pending changes does, and stays put +/// while it doesn't. `None` when nothing is pending. +/// +/// For a background syncer deciding whether there is anything NEW to send. +/// `has_pending` cannot answer that: a change the server rejected stays dirty on +/// purpose (it needs a person), so "something is pending" stays true after every +/// cycle, and a loop keyed on it would resend the same rejected row forever. Any +/// local write moves a count or stamps a later `updated_at`, so it moves this too. +pub fn pending_fingerprint(conn: &Connection) -> rusqlite::Result> { + let fingerprint: String = conn.query_row( + "SELECT (SELECT COUNT(*) || ':' || IFNULL(MAX(updated_at), '') FROM notes WHERE dirty = 1) + || '|' || (SELECT COUNT(*) || ':' || IFNULL(MAX(updated_at), '') FROM labels WHERE dirty = 1) + || '|' || (SELECT COUNT(*) || ':' || IFNULL(MAX(deleted_at), '') FROM pending_deletes)", + [], + |r| r.get(0), + )?; + Ok((fingerprint != "0:|0:|0:").then_some(fingerprint)) +} + /// Send everything pending, in batches, applying each batch's results before the /// next is collected. pub async fn run(db: &Db, base_url: &str, token: &str) -> Result { @@ -711,4 +730,35 @@ mod tests { "a delete carries no content: {json}" ); } + + #[test] + fn the_pending_fingerprint_moves_only_when_the_pending_set_does() { + let conn = db(); + assert_eq!(pending_fingerprint(&conn).unwrap(), None); + + seed_note(&conn, "n1", 1); + let one = pending_fingerprint(&conn).unwrap().expect("one dirty note"); + // Asking again changes nothing: a rejected row left dirty is not news. + assert_eq!(pending_fingerprint(&conn).unwrap(), Some(one.clone())); + + seed_note(&conn, "n2", 1); + let two = pending_fingerprint(&conn) + .unwrap() + .expect("two dirty notes"); + assert_ne!(one, two, "another pending note is new work"); + + conn.execute( + "UPDATE notes SET updated_at = '2026-07-27T00:00:00.000Z' WHERE id = 'n1'", + [], + ) + .unwrap(); + assert_ne!( + pending_fingerprint(&conn).unwrap(), + Some(two), + "a fresh edit is new work" + ); + + conn.execute("UPDATE notes SET dirty = 0", []).unwrap(); + assert_eq!(pending_fingerprint(&conn).unwrap(), None); + } } diff --git a/desktop/src-tauri/src/autosync.rs b/desktop/src-tauri/src/autosync.rs new file mode 100644 index 0000000..61ca635 --- /dev/null +++ b/desktop/src-tauri/src/autosync.rs @@ -0,0 +1,393 @@ +//! The desktop syncs on its own (#5167). +//! +//! Until this existed, the only thing that ever called the sync engine was the +//! "Sync now" button on the Sync screen. A linked desktop sat on its edits until +//! someone went looking for that button, and never heard about a note written on +//! the phone. Android has done better from the start (a periodic WorkManager job, +//! plus a flush when the app leaves the foreground); this is the desktop's version. +//! +//! ## When a cycle runs +//! +//! - **At launch**, so the board shows what changed while the app was closed. +//! - **Soon after an edit.** Every [`TICK`] the worker reads a fingerprint of what +//! is waiting to be sent (one cheap query), and sends it if that changed since the +//! last cycle. That puts an edit on the server within about ten seconds without a +//! hook in every command that writes, and a burst of autosaves still costs one +//! cycle per tick. +//! +//! The fingerprint, not "is anything pending", and that is the loop's fixed +//! point. A change the server rejects stays pending on purpose — it needs a +//! person — so "pending" would stay true after every cycle and the worker would +//! resend the same rejected row every ten seconds, forever, reporting success +//! each time. A rejected change still goes again with the next real edit, the +//! periodic cycle, or the button. +//! - **Every [`PULL_EVERY`]** even with nothing to send, so a quiet desktop still +//! receives what other devices wrote. +//! - **When the window regains focus**, which is when someone is about to look. +//! - **When asked**: the button, and the cycle right after linking. +//! +//! ## One cycle at a time, by construction +//! +//! Every cycle, the button's included, runs on this one worker thread. There is no +//! lock to forget: two cycles cannot overlap because there is only one place that +//! runs them. +//! +//! ## Failing quietly, but not silently +//! +//! An unreachable server is the normal state of a laptop on a train, so a failed +//! automatic cycle is not an error dialog. It backs off (doubling from [`TICK`] up +//! to [`MAX_BACKOFF`]), and its message is kept in [`LastCycle`] for the Sync +//! screen to show. The button ignores the backoff: a person pressing it wants to +//! know now. +//! +//! Not done: a final push when the app quits. Each request is bounded by the +//! client's timeout, but holding the window open for one on an offline laptop is a +//! worse quit than leaving the edit for the launch cycle, which sends it first. + +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager}; + +use inkwell_core::local::Db; +use inkwell_core::sync::blobs::BlobStore; +use inkwell_core::sync::engine::{self, SyncOutcome}; +use inkwell_core::sync::{push, state}; + +/// How often the worker wakes to look for unsent edits. +const TICK: Duration = Duration::from_secs(10); +/// The longest a linked, idle desktop goes without asking the server for news. +const PULL_EVERY: Duration = Duration::from_secs(5 * 60); +/// The ceiling on the wait after repeated failures. +const MAX_BACKOFF: Duration = Duration::from_secs(5 * 60); +/// Focus comes and goes constantly; it may start a cycle at most this often. +const FOCUS_GAP: Duration = Duration::from_secs(30); + +/// Emitted to every window after a cycle, with a [`LastCycle`] as the payload. +/// The board reloads when `changed` is true; the Sync screen redraws its status. +pub const SYNCED_EVENT: &str = "inkwell://synced"; + +/// What started a cycle. Shown on the Sync screen, so an automatic sync can be told +/// apart from a button press. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Trigger { + Launch, + Edit, + Timer, + Focus, + Manual, +} + +/// The outcome of the most recent cycle, automatic or not. +#[derive(Debug, Clone, Serialize)] +pub struct LastCycle { + /// When it finished, in milliseconds since the epoch — the frontend formats it. + pub at_ms: u64, + pub trigger: Trigger, + pub ok: bool, + /// Why it failed, in words a person can act on. None when `ok`. + pub error: Option, + /// Whether the pull changed anything this device shows. + pub changed: bool, + /// The full result, for the screen that reports counts. None on failure. + pub outcome: Option, +} + +type Reply = Sender>; + +struct Request { + trigger: Trigger, + /// Present when someone is waiting on the result (the button). + reply: Option, +} + +/// Managed state: the way into the worker, and the last thing it did. +pub struct AutoSync { + tx: Mutex>, + last: Mutex>, +} + +impl AutoSync { + fn send(&self, request: Request) -> Result<(), String> { + self.tx + .lock() + .map_err(|e| e.to_string())? + .send(request) + .map_err(|_| "The sync worker has stopped.".to_string()) + } + + /// Ask for a cycle without waiting for it. Subject to the worker's backoff and, + /// for focus, to [`FOCUS_GAP`]. + pub fn kick(&self, trigger: Trigger) { + if let Err(e) = self.send(Request { + trigger, + reply: None, + }) { + log::warn!("could not request a {trigger:?} sync: {e}"); + } + } + + pub fn last(&self) -> Option { + self.last.lock().ok().and_then(|l| l.clone()) + } +} + +/// Start the worker and put [`AutoSync`] in managed state. Called from `setup`, +/// after the store and the blob store are managed — the worker reads both. +pub fn start(app: &AppHandle) -> std::io::Result<()> { + let (tx, rx) = mpsc::channel(); + app.manage(AutoSync { + tx: Mutex::new(tx), + last: Mutex::new(None), + }); + let handle = app.clone(); + std::thread::Builder::new() + .name("autosync".into()) + .spawn(move || worker(handle, rx))?; + Ok(()) +} + +/// Run one cycle on the worker and wait for it — the "Sync now" button. +pub async fn run_now(app: &AppHandle) -> Result { + let (reply, wait) = mpsc::channel(); + app.state::().send(Request { + trigger: Trigger::Manual, + reply: Some(reply), + })?; + // `recv` blocks, so it runs on the blocking pool rather than stalling the + // async runtime that every other command shares. + tauri::async_runtime::spawn_blocking(move || wait.recv()) + .await + .map_err(|e| e.to_string())? + .map_err(|_| "The sync worker stopped before answering.".to_string())? +} + +/// How long to wait after `failures` failed cycles in a row. +fn backoff(failures: u32) -> Duration { + if failures == 0 { + return Duration::ZERO; + } + TICK.saturating_mul(1 << failures.min(10)).min(MAX_BACKOFF) +} + +/// The server URL and token, or None when this device isn't linked. +fn credentials(db: &Db) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + let current = state::read(&conn).map_err(|e| e.to_string())?; + Ok(current.server_url.zip(current.device_token)) +} + +/// What is waiting to be sent, as [`push::pending_fingerprint`] sees it. A store +/// that can't be read reads as nothing pending; the periodic cycle still runs. +fn pending(db: &Db) -> Option { + let conn = db.0.lock().ok()?; + push::pending_fingerprint(&conn).ok().flatten() +} + +/// Whether a cycle's pull changed anything the board shows. +fn pulled_changes(outcome: &SyncOutcome) -> bool { + let p = &outcome.pull; + p.notes_applied + p.notes_deleted + p.labels_applied + p.labels_deleted + p.blobs_downloaded > 0 +} + +/// The worker's whole state between cycles. +#[derive(Default)] +struct Pace { + last_attempt: Option, + last_success: Option, + failures: u32, + /// The pending fingerprint as the last cycle left it. + sent: Option, +} + +impl Pace { + /// Whether an automatic trigger may start a cycle now. The button never asks. + fn allows(&self, trigger: Trigger, now: Instant) -> bool { + let since_attempt = self.last_attempt.map(|t| now.duration_since(t)); + // After a failure nothing automatic runs until the backoff has passed. + if since_attempt.is_some_and(|d| d < backoff(self.failures)) { + return false; + } + match trigger { + Trigger::Manual | Trigger::Launch | Trigger::Edit => true, + Trigger::Focus => since_attempt.is_none_or(|d| d >= FOCUS_GAP), + Trigger::Timer => self + .last_success + .is_none_or(|t| now.duration_since(t) >= PULL_EVERY), + } + } +} + +fn worker(app: AppHandle, rx: Receiver) { + let mut pace = Pace::default(); + let mut next = Some(Request { + trigger: Trigger::Launch, + reply: None, + }); + loop { + let request = match next.take() { + Some(request) => Some(request), + None => match rx.recv_timeout(TICK) { + Ok(request) => Some(request), + Err(RecvTimeoutError::Timeout) => None, + // The app is shutting down: every sender went with the managed state. + Err(RecvTimeoutError::Disconnected) => return, + }, + }; + let db = app.state::(); + + let (trigger, reply) = match request { + Some(Request { trigger, reply }) => (trigger, reply), + // A quiet tick: new edits waiting to go, or the periodic pull. + None => match pending(&db) { + Some(now) if pace.sent.as_ref() != Some(&now) => (Trigger::Edit, None), + _ => (Trigger::Timer, None), + }, + }; + if reply.is_none() && !pace.allows(trigger, Instant::now()) { + continue; + } + + let (base_url, token) = match credentials(&db) { + Ok(Some(creds)) => creds, + Ok(None) => { + // Unlinked is the normal resting state, not a failure. + if let Some(reply) = reply { + let _ = reply.send(Err("This app isn't linked to a server yet.".to_string())); + } + continue; + } + Err(e) => { + if let Some(reply) = reply { + let _ = reply.send(Err(e)); + } + continue; + } + }; + + let blobs = app.state::(); + let started = Instant::now(); + // A panic inside one cycle must not end automatic sync for the rest of the + // session: this thread is the only thing that runs it, and a dead thread + // looks exactly like a quiet one. It becomes a failed cycle instead. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tauri::async_runtime::block_on(engine::run_cycle( + db.inner(), + blobs.inner(), + &base_url, + &token, + )) + })) + .unwrap_or_else(|_| Err("The sync cycle crashed; it will be retried.".to_string())); + pace.last_attempt = Some(started); + // Whatever is still pending after this cycle is not new on the next tick. + pace.sent = pending(&db); + match &result { + Ok(_) => { + pace.failures = 0; + pace.last_success = Some(started); + } + Err(e) => { + pace.failures = pace.failures.saturating_add(1); + log::warn!( + "{trigger:?} sync failed ({} in a row, next automatic try in {:?}): {e}", + pace.failures, + backoff(pace.failures) + ); + } + } + + let cycle = LastCycle { + at_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64), + trigger, + ok: result.is_ok(), + error: result.as_ref().err().cloned(), + changed: result.as_ref().is_ok_and(pulled_changes), + outcome: result.as_ref().ok().cloned(), + }; + if let Ok(mut last) = app.state::().last.lock() { + *last = Some(cycle.clone()); + } + if let Err(e) = app.emit(SYNCED_EVENT, &cycle) { + log::warn!("could not announce the finished sync: {e}"); + } + if let Some(reply) = reply { + let _ = reply.send(result); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_doubles_from_the_tick_and_stops_at_the_ceiling() { + assert_eq!(backoff(0), Duration::ZERO); + assert_eq!(backoff(1), TICK * 2); + assert_eq!(backoff(2), TICK * 4); + assert_eq!(backoff(40), MAX_BACKOFF); + } + + #[test] + fn a_fresh_worker_runs_every_trigger() { + let pace = Pace::default(); + let now = Instant::now(); + for trigger in [ + Trigger::Launch, + Trigger::Edit, + Trigger::Timer, + Trigger::Focus, + Trigger::Manual, + ] { + assert!(pace.allows(trigger, now), "{trigger:?}"); + } + } + + #[test] + fn after_a_failure_automatic_triggers_wait_out_the_backoff() { + let start = Instant::now(); + let pace = Pace { + last_attempt: Some(start), + last_success: None, + failures: 1, + ..Pace::default() + }; + assert!(!pace.allows(Trigger::Edit, start + TICK)); + assert!(!pace.allows(Trigger::Focus, start + TICK)); + assert!(pace.allows(Trigger::Edit, start + backoff(1))); + } + + #[test] + fn the_timer_only_pulls_once_the_interval_has_passed() { + let start = Instant::now(); + let pace = Pace { + last_attempt: Some(start), + last_success: Some(start), + failures: 0, + ..Pace::default() + }; + assert!(!pace.allows(Trigger::Timer, start + TICK)); + assert!(pace.allows(Trigger::Timer, start + PULL_EVERY)); + // An edit does not wait for the interval. + assert!(pace.allows(Trigger::Edit, start + TICK)); + } + + #[test] + fn focus_is_rate_limited() { + let start = Instant::now(); + let pace = Pace { + last_attempt: Some(start), + last_success: Some(start), + failures: 0, + ..Pace::default() + }; + assert!(!pace.allows(Trigger::Focus, start + Duration::from_secs(5))); + assert!(pace.allows(Trigger::Focus, start + FOCUS_GAP)); + } +} diff --git a/desktop/src-tauri/src/commands/sync.rs b/desktop/src-tauri/src/commands/sync.rs index 5b38d3f..e188868 100644 --- a/desktop/src-tauri/src/commands/sync.rs +++ b/desktop/src-tauri/src/commands/sync.rs @@ -4,16 +4,17 @@ //! any of this. The Settings UI (M10.7e) drives these. use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; use inkwell_core::local::Db; -use inkwell_core::sync::blobs::BlobStore; use inkwell_core::sync::client::{self, Identity, ProbeResult}; use inkwell_core::sync::compat::Compatibility; use inkwell_core::sync::engine; use inkwell_core::sync::push; use inkwell_core::sync::state; +use crate::autosync::{self, AutoSync, LastCycle}; + /// Ask a server who it is, without committing to anything. The UI calls this as the /// user finishes typing an address, so they see what answered before handing over /// credentials. @@ -156,29 +157,24 @@ pub fn sync_status(db: State<'_, Db>) -> Result { state::status(&conn).map_err(|e| e.to_string()) } -/// The server URL + token, or a plain "not linked" error. Every networked sync -/// command needs exactly this, and none of them may hold the lock past it. -fn credentials(db: &State<'_, Db>) -> Result<(String, String), String> { - let conn = db.0.lock().map_err(|e| e.to_string())?; - let current = state::read(&conn).map_err(|e| e.to_string())?; - match (current.server_url, current.device_token) { - (Some(url), Some(token)) => Ok((url, token)), - _ => Err("This app isn't linked to a server yet.".to_string()), - } -} - /// Run one full sync: push local changes, then pull the server's. /// /// The only sync entry point exposed to the UI, on purpose. Push and pull exist /// separately inside the crate, but offering a bare "pull" would let the UI overwrite /// unsent local edits — the ordering isn't a suggestion, it's what keeps them. +/// +/// Runs on the background worker (autosync.rs) and waits for it, so a button press +/// can never overlap an automatic cycle. #[tauri::command] -pub async fn sync_now( - db: State<'_, Db>, - blobs: State<'_, BlobStore>, -) -> Result { - let (base_url, token) = credentials(&db)?; - engine::run_cycle(db.inner(), blobs.inner(), &base_url, &token).await +pub async fn sync_now(app: AppHandle) -> Result { + autosync::run_now(&app).await +} + +/// The most recent cycle, automatic or not — what the Sync screen shows now that +/// most cycles happen without anyone pressing anything. None until the first one. +#[tauri::command] +pub fn sync_last(autosync: State<'_, AutoSync>) -> Option { + autosync.last() } /// Whether anything is waiting to be sent. Lets the UI show an honest "unsynced diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 87544de..8f7e1aa 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ //! remains here is the Tauri command surface (`commands`), desktop integration //! (menu-entry install for the Linux AppImage), the in-app updater, and boot. +mod autosync; mod capture; mod commands; mod crossover; @@ -142,6 +143,21 @@ pub fn run() { // was built before this path could be resolved. sync::blobs::publish_root(blobs.root().to_path_buf()); app.manage(blobs); + // Last: the worker reads the store and the blob store, both managed now. + // Its first cycle is the launch sync. + autosync::start(app.handle())?; + // Coming back to the window is when someone is about to look, so it asks + // for a cycle. Rate-limited in autosync, because focus flaps constantly. + if let Some(window) = app.get_webview_window("main") { + let handle = app.handle().clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(true) = event { + handle + .state::() + .kick(autosync::Trigger::Focus); + } + }); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -186,6 +202,7 @@ pub fn run() { commands::sync::sync_unlink, commands::sync::sync_status, commands::sync::sync_now, + commands::sync::sync_last, commands::sync::sync_has_pending, update::update_channel_get, update::update_channel_set, diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index cdcb16e..1255c9f 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -157,6 +157,38 @@ export interface LinkInput { name?: string; } +/** What started a sync cycle (autosync.rs). */ +export type SyncTrigger = "launch" | "edit" | "timer" | "focus" | "manual"; + +/** + * The most recent cycle, automatic or not. The desktop syncs on its own (at launch, + * soon after an edit, every few minutes and on focus), so most cycles have no + * button press to report back to — this is how the screen hears about them. + */ +export interface LastCycle { + at_ms: number; + trigger: SyncTrigger; + ok: boolean; + error: string | null; + /** Whether the pull changed anything this device shows. */ + changed: boolean; + outcome: SyncOutcome | null; +} + +/** Fired at every window after each cycle, with a LastCycle as the payload. */ +const SYNCED_EVENT = "inkwell://synced"; + +/** + * Run `handler` after every sync cycle. Returns an unlisten function, or a no-op + * where there is no event API (the web build) — nothing syncs in the background + * there, so there is nothing to hear. + */ +export async function onSynced(handler: (cycle: LastCycle) => void): Promise<() => void> { + const events = window.__TAURI__?.event; + if (!events) return () => {}; + return events.listen(SYNCED_EVENT, (e) => handler(e.payload)); +} + export const sync = { /** Ask who's at an address without committing to anything. */ probe: (url: string) => invoke("sync_probe", { url }), @@ -169,6 +201,8 @@ export const sync = { * without pushing first overwrites unsent local edits. */ now: () => invoke("sync_now"), + /** The last cycle, or null before the first one. */ + last: () => invoke("sync_last"), hasPending: () => invoke("sync_has_pending"), }; diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index 8c4f2bb..ae04364 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue"; import FilterBar from "../components/FilterBar.vue"; import NoteGrid from "../components/NoteGrid.vue"; import NoteEditor from "../components/NoteEditor.vue"; -import { isDesktop, onCaptured, sync as syncBridge } from "../desktop/bridge"; +import { isDesktop, onCaptured, onSynced, sync as syncBridge } from "../desktop/bridge"; const notes = useNotesStore(); const config = useConfigStore(); @@ -233,15 +233,25 @@ onMounted(() => { // unregistered on the way out so a board that has been navigated away from does // not keep reloading itself. let stopCaptureListener: (() => void) | null = null; +// The desktop syncs in the background (autosync.rs). A cycle that pulled something +// changed SQLite under this board, so it reloads — only then, so the cycles that +// change nothing (most of them) cost the board nothing. +let stopSyncListener: (() => void) | null = null; onMounted(() => { void onCaptured(() => void reload()).then((stop) => { stopCaptureListener = stop; }); + void onSynced((cycle) => { + if (cycle.changed) void reload(); + }).then((stop) => { + stopSyncListener = stop; + }); }); onBeforeUnmount(() => { window.removeEventListener("keydown", onBoardKey); stopCaptureListener?.(); + stopSyncListener?.(); ui.boardCardFocused = false; }); watch([currentView, currentLabel, facetKey], reload); diff --git a/frontend/src/views/SyncView.vue b/frontend/src/views/SyncView.vue index d26fc96..1bca2e5 100644 --- a/frontend/src/views/SyncView.vue +++ b/frontend/src/views/SyncView.vue @@ -1,5 +1,5 @@