From fe683595df13837dc436aeec434232f0bed7efc7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 26 Jul 2026 00:30:37 -0400 Subject: [PATCH] M10.7e: desktop Sync settings screen (task 2108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface that turns the engine into a feature (rule 27). Desktop-only — the web build IS a server's UI, so a "connect a server" screen there would be nonsense; the route redirects to the board and the nav entry is hidden. UNLINKED IS THE RESTING STATE, not an incomplete setup. The empty case leads with "Working offline on this device — everything works without a server", because a screen that framed the default as a problem would push people into configuring something they may never need. The app is local-first; this is opt-in. Probe before credentials. "Check" shows who actually answered — site name, version, and the M10.6 verdict — before any password or token is typed. An incompatible server is shown in red and the sign-in fields never appear, so you cannot hand a credential to something that can't use it. `degraded` names the missing capabilities rather than staying quiet and letting a feature mysteriously do nothing. Both credential paths, matching the Rust side: email+password (a fresh install has no session to mint a token from) or a pasted device token (for anyone who'd rather not type a password into a desktop app). Secrets are cleared from component state the moment they're exchanged. Disconnect states plainly that the token stays valid server-side and points at Account -> Linked devices, rather than implying a remote revoke that didn't happen (issue 2110). Wording avoids "revoke" for exactly that reason. Push rejections are surfaced verbatim after a sync, never swallowed — a duplicate label name is the realistic case and only a person can resolve it. Adds schema v3: last_sync_at. The cursor can't answer "am I up to date?" — it's a revision watermark, not a time, and it doesn't move at all when a sync legitimately finds nothing new, so "synced a moment ago, nothing new" would be indistinguishable from "never synced". Stamped only after BOTH halves of the cycle succeed; a stamp after a partial cycle would claim currency the data doesn't have. Cleared on unlink so a new server can't inherit it. run_cycle now returns the post-cycle status, so the UI updates from one round-trip instead of chasing every sync with a status call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi --- desktop/src-tauri/src/local/schema.rs | 13 + desktop/src-tauri/src/sync/engine.rs | 21 +- desktop/src-tauri/src/sync/state.rs | 33 ++- frontend/src/components/AppShell.vue | 12 + frontend/src/components/Icon.vue | 1 + frontend/src/desktop/bridge.ts | 97 ++++++++ frontend/src/router/index.ts | 13 +- frontend/src/views/SyncView.vue | 346 ++++++++++++++++++++++++++ 8 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 frontend/src/views/SyncView.vue diff --git a/desktop/src-tauri/src/local/schema.rs b/desktop/src-tauri/src/local/schema.rs index 190e26b..e85c823 100644 --- a/desktop/src-tauri/src/local/schema.rs +++ b/desktop/src-tauri/src/local/schema.rs @@ -121,6 +121,15 @@ CREATE TABLE pending_deletes ( ); "#; +// v3 (M10.7e): when the last successful sync finished. +// +// The cursor alone can't answer "is this up to date?" — it's a revision watermark, +// not a time, and it doesn't move at all when a sync legitimately finds nothing new. +// The UI needs a timestamp to say anything honest. +const SCHEMA_V3: &str = r#" +ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT; +"#; + /// Bring the database up to the latest schema. Idempotent. pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch("PRAGMA foreign_keys = ON;")?; @@ -133,5 +142,9 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch(SCHEMA_V2)?; conn.execute_batch("PRAGMA user_version = 2;")?; } + if version < 3 { + conn.execute_batch(SCHEMA_V3)?; + conn.execute_batch("PRAGMA user_version = 3;")?; + } Ok(()) } diff --git a/desktop/src-tauri/src/sync/engine.rs b/desktop/src-tauri/src/sync/engine.rs index b73911e..c1674ee 100644 --- a/desktop/src-tauri/src/sync/engine.rs +++ b/desktop/src-tauri/src/sync/engine.rs @@ -4,16 +4,21 @@ //! own inside this crate, but exposing them separately would let a caller pull //! without pushing, which quietly overwrites unsent local edits. +use chrono::{SecondsFormat, Utc}; use serde::Serialize; use super::pull; use super::push; +use super::state; use crate::local::Db; #[derive(Debug, Serialize)] pub struct SyncOutcome { pub push: push::PushSummary, pub pull: pull::PullSummary, + /// The state after the cycle, so the UI updates from one round-trip instead of + /// following every sync with a status call. + pub status: state::Status, } /// Push, then pull — in that order, always. @@ -39,5 +44,19 @@ pub async fn run_cycle(db: &Db, base_url: &str, token: &str) -> Result, pub device_token: Option, pub last_cursor: i64, + pub last_sync_at: Option, } impl SyncState { @@ -37,6 +38,7 @@ pub struct Status { pub linked: bool, pub server_url: Option, pub last_cursor: i64, + pub last_sync_at: Option, } impl From<&SyncState> for Status { @@ -45,6 +47,7 @@ impl From<&SyncState> for Status { linked: s.is_linked(), server_url: s.server_url.clone(), last_cursor: s.last_cursor, + last_sync_at: s.last_sync_at.clone(), } } } @@ -56,11 +59,12 @@ fn present(value: Option) -> Option { pub fn read(conn: &Connection) -> rusqlite::Result { conn.query_row( - "SELECT server_url, device_token, last_cursor FROM sync_state WHERE id = 1", + "SELECT server_url, device_token, last_cursor, last_sync_at FROM sync_state WHERE id = 1", [], |row| { let cursor: Option = row.get(2)?; Ok(SyncState { + last_sync_at: present(row.get(3)?), server_url: present(row.get(0)?), device_token: present(row.get(1)?), // Stored TEXT (schema) but used as an integer watermark. Absent or @@ -101,13 +105,26 @@ pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusq pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state - SET server_url = NULL, device_token = NULL, last_cursor = NULL + SET server_url = NULL, device_token = NULL, last_cursor = NULL, + last_sync_at = NULL WHERE id = 1", [], )?; Ok(()) } +/// Stamp a completed sync. The cursor can't stand in for this: it's a revision +/// watermark, and it doesn't move at all when a sync correctly finds nothing new — +/// so "synced a moment ago, no changes" would be indistinguishable from "never +/// synced" without it. +pub fn mark_synced(conn: &Connection, when: &str) -> rusqlite::Result<()> { + conn.execute( + "UPDATE sync_state SET last_sync_at = ?1 WHERE id = 1", + params![when], + )?; + Ok(()) +} + /// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only /// after a page has been fully applied. pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> { @@ -196,6 +213,18 @@ mod tests { assert!(state.device_token.is_none()); } + #[test] + fn unlink_clears_the_last_sync_stamp() { + // Otherwise a freshly-linked server would claim it synced at a time that + // belonged to a different one. + let conn = db(); + set_link(&conn, "https://a.example.com", "tok-1").expect("link"); + mark_synced(&conn, "2026-07-26T04:00:00.000Z").expect("stamp"); + assert!(read(&conn).expect("read").last_sync_at.is_some()); + clear_link(&conn).expect("unlink"); + assert!(read(&conn).expect("read").last_sync_at.is_none()); + } + #[test] fn half_written_link_is_not_linked() { let conn = db(); diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index fac73a3..f183573 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -12,6 +12,7 @@ import CommandPalette from "./CommandPalette.vue"; import Icon from "./Icon.vue"; import ImportNotes from "./ImportNotes.vue"; import LabelsModal from "./LabelsModal.vue"; +import { isDesktop } from "../desktop/bridge"; import { facetsToQuery } from "../notes/facets"; import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; @@ -23,6 +24,8 @@ const labels = useLabelsStore(); const savedFilters = useSavedFiltersStore(); const reminders = useReminderStore(); const ui = useUiStore(); +// Sync is a desktop-app concern: the web build already IS the server's UI. +const desktopApp = isDesktop(); async function removeView(f: SavedFilter) { if (!window.confirm(`Delete the "${f.name}" view?`)) return; @@ -260,6 +263,15 @@ async function signOut() { + + + diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 95d5111..4a55a1d 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -26,6 +26,7 @@ const paths: Record = { download: '', upload: '', device: '', + sync: '', paperclip: '', link: '', filter: '', diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index 3d8a33b..9eafae9 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -47,3 +47,100 @@ export const desktop = { integrate: () => invoke("integrate_desktop"), unintegrate: () => invoke("unintegrate_desktop"), }; + +// --- opt-in server sync (M10.7) ---------------------------------------------- +// The desktop app is local-first: none of this runs unless the user links a server, +// and the app is fully usable having never done so. + +/** Never carries the device token — that stays on the Rust side, out of the webview. */ +export interface SyncStatus { + linked: boolean; + server_url: string | null; + last_cursor: number; + last_sync_at: string | null; +} + +export interface ServerInfo { + site_name: string | null; + version: string | null; + sync_protocol_version: number | null; + min_client_protocol_version: number | null; + sync_features: string[]; +} + +/** + * The M10.6 handshake verdict. `incompatible` carries `client_must_update` so the + * message can name which side has to change rather than just saying "incompatible". + */ +export type Compatibility = + | { status: "ok" } + | { status: "degraded"; unavailable: string[] } + | { status: "incompatible"; reason: string; client_must_update: boolean }; + +export interface ProbeResult { + base_url: string; + server: ServerInfo; + compatibility: Compatibility; +} + +export interface Identity { + id: string; + email: string; + display_name: string; +} + +export interface LinkResult { + status: SyncStatus; + identity: Identity; + compatibility: Compatibility; +} + +export interface PushSummary { + batches: number; + sent: number; + created: number; + applied: number; + kept: number; + noop: number; + rejected: number; + errors: string[]; +} + +export interface PullSummary { + pages: number; + notes_applied: number; + notes_deleted: number; + labels_applied: number; + labels_deleted: number; + cursor: number; + clobbered_dirty: number; +} + +export interface SyncOutcome { + push: PushSummary; + pull: PullSummary; + status: SyncStatus; +} + +/** Either a password login or a token pasted from the web app's Linked devices. */ +export interface LinkInput { + url: string; + email?: string; + password?: string; + token?: string; + name?: string; +} + +export const sync = { + /** Ask who's at an address without committing to anything. */ + probe: (url: string) => invoke("sync_probe", { url }), + link: (input: LinkInput) => invoke("sync_link", { input }), + unlink: () => invoke("sync_unlink"), + status: () => invoke("sync_status"), + /** + * One full cycle: push, then pull. There is deliberately no bare "pull" — pulling + * without pushing first overwrites unsent local edits. + */ + now: () => invoke("sync_now"), + hasPending: () => invoke("sync_has_pending"), +}; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index aae7126..29fed2c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,7 +1,7 @@ import { createRouter, createWebHistory } from "vue-router"; import { useSessionStore } from "../stores/session"; import { useConfigStore } from "../stores/config"; -import { logEvent } from "../desktop/bridge"; +import { isDesktop, logEvent } from "../desktop/bridge"; // One-time boot diagnostic: the first navigation is where config + session resolve, // so it's the moment that tells us whether the app got past its startup gate. @@ -32,6 +32,14 @@ const router = createRouter({ component: () => import("../views/SettingsView.vue"), meta: { requiresAuth: true, requiresAdmin: true }, }, + { + // Desktop only: connect this app to a server. Meaningless in the web build, + // which IS a server's UI — there's nothing for it to link to. + path: "/sync", + name: "sync", + component: () => import("../views/SyncView.vue"), + meta: { requiresAuth: true, requiresDesktop: true }, + }, { // Per-user account: linked devices (native-client sync tokens). Any user. path: "/account", @@ -74,6 +82,9 @@ router.beforeEach(async (to) => { if (to.meta.requiresAdmin && !session.user?.is_admin) { return { name: "board" }; } + if (to.meta.requiresDesktop && !isDesktop()) { + return { name: "board" }; + } if (to.name === "register" && !config.allowRegistration) { return { name: "login" }; } diff --git a/frontend/src/views/SyncView.vue b/frontend/src/views/SyncView.vue new file mode 100644 index 0000000..0c9b1a2 --- /dev/null +++ b/frontend/src/views/SyncView.vue @@ -0,0 +1,346 @@ + + +