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