M10.7e: desktop Sync settings screen (task 2108)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
2026-07-26 00:30:37 -04:00
co-authored by Claude Opus 5
parent 75b2d096ec
commit fe683595df
8 changed files with 532 additions and 4 deletions
+12
View File
@@ -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() {
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{
session.user?.display_name
}}</span>
<RouterLink
v-if="desktopApp"
to="/sync"
class="icon-btn"
title="Sync"
aria-label="Sync"
>
<Icon name="sync" />
</RouterLink>
<RouterLink to="/account" class="icon-btn" title="Linked devices" aria-label="Linked devices">
<Icon name="device" />
</RouterLink>
+1
View File
@@ -26,6 +26,7 @@ const paths: Record<string, string> = {
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
device: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
sync: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
+97
View File
@@ -47,3 +47,100 @@ export const desktop = {
integrate: () => invoke<IntegrationStatus>("integrate_desktop"),
unintegrate: () => invoke<IntegrationStatus>("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<ProbeResult>("sync_probe", { url }),
link: (input: LinkInput) => invoke<LinkResult>("sync_link", { input }),
unlink: () => invoke<SyncStatus>("sync_unlink"),
status: () => invoke<SyncStatus>("sync_status"),
/**
* One full cycle: push, then pull. There is deliberately no bare "pull" — pulling
* without pushing first overwrites unsent local edits.
*/
now: () => invoke<SyncOutcome>("sync_now"),
hasPending: () => invoke<boolean>("sync_has_pending"),
};
+12 -1
View File
@@ -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" };
}
+346
View File
@@ -0,0 +1,346 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import Icon from "../components/Icon.vue";
import {
sync as syncBridge,
type Compatibility,
type ProbeResult,
type SyncStatus,
} from "../desktop/bridge";
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting
// state, not an incomplete setup — the app is local-first and fully usable having
// never touched this screen. The copy has to carry that, or every new user will
// think something is broken.
const ui = useUiStore();
const status = ref<SyncStatus | null>(null);
const pending = ref(false);
const loading = ref(true);
// Connect form
const url = ref("");
const mode = ref<"password" | "token">("password");
const email = ref("");
const password = ref("");
const token = ref("");
const deviceName = ref("");
const probing = ref(false);
const probe = ref<ProbeResult | null>(null);
const probeError = ref("");
const linking = ref(false);
const linkError = ref("");
const linkedAs = ref("");
const degraded = ref<string[]>([]);
const syncing = ref(false);
const syncError = ref("");
const lastResult = ref("");
const linked = computed(() => status.value?.linked === true);
/** Only offer to connect once a probe has said the server is usable. */
const canLink = computed(() => {
if (!probe.value || probe.value.compatibility.status === "incompatible") return false;
return mode.value === "token" ? token.value.trim().length > 0 : email.value.trim().length > 0 && password.value.length > 0;
});
function describe(c: Compatibility): string {
if (c.status === "ok") return "Fully compatible.";
if (c.status === "degraded") {
return `Compatible, but these features aren't available on this server: ${c.unavailable.join(", ")}.`;
}
return c.reason;
}
async function refresh() {
try {
status.value = await syncBridge.status();
pending.value = await syncBridge.hasPending();
} catch {
status.value = null;
} finally {
loading.value = false;
}
}
async function runProbe() {
probing.value = true;
probeError.value = "";
probe.value = null;
try {
probe.value = await syncBridge.probe(url.value);
} catch (e) {
probeError.value = String((e as Error)?.message ?? e);
} finally {
probing.value = false;
}
}
async function connect() {
linking.value = true;
linkError.value = "";
try {
const result = await syncBridge.link({
url: probe.value?.base_url ?? url.value,
email: mode.value === "password" ? email.value.trim() : undefined,
password: mode.value === "password" ? password.value : undefined,
token: mode.value === "token" ? token.value.trim() : undefined,
name: deviceName.value.trim() || undefined,
});
status.value = result.status;
linkedAs.value = result.identity.email;
degraded.value =
result.compatibility.status === "degraded" ? result.compatibility.unavailable : [];
// Never keep the secrets around after they've been exchanged for a token.
password.value = "";
token.value = "";
probe.value = null;
ui.showToast(`Connected to ${result.status.server_url}.`);
await syncNow();
} catch (e) {
linkError.value = String((e as Error)?.message ?? e);
} finally {
linking.value = false;
}
}
async function syncNow() {
syncing.value = true;
syncError.value = "";
try {
const outcome = await syncBridge.now();
status.value = outcome.status;
pending.value = await syncBridge.hasPending();
const received = outcome.pull.notes_applied + outcome.pull.notes_deleted;
const sent = outcome.push.created + outcome.push.applied;
lastResult.value =
received === 0 && sent === 0
? "Already up to date."
: `Sent ${sent}, received ${received}.`;
// Rejections are the server refusing a specific change — surfaced, never
// swallowed, because only the person can resolve them.
if (outcome.push.rejected > 0) {
syncError.value = `${outcome.push.rejected} change(s) the server wouldn't accept: ${outcome.push.errors.join("; ")}`;
}
} catch (e) {
syncError.value = String((e as Error)?.message ?? e);
} finally {
syncing.value = false;
}
}
async function disconnect() {
if (
!window.confirm(
"Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. The device token remains valid until you revoke it on the server under Account → Linked devices.",
)
) {
return;
}
try {
status.value = await syncBridge.unlink();
linkedAs.value = "";
degraded.value = [];
lastResult.value = "";
ui.showToast("Disconnected. This device now works offline only.");
} catch (e) {
ui.showToast(String((e as Error)?.message ?? e));
}
}
function fmt(iso: string | null): string {
if (!iso) return "never";
return new Date(iso).toLocaleString();
}
onMounted(refresh);
</script>
<template>
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
<header class="mb-8 flex items-center gap-3">
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
<svg
class="h-[18px] w-[18px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</RouterLink>
<h1 class="text-xl font-bold tracking-tight">Sync</h1>
</header>
<div v-if="loading" class="py-10 text-center text-sm text-neutral-400">Loading</div>
<!-- Linked -->
<template v-else-if="linked">
<section class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Connected to
<span class="font-mono text-xs">{{ status?.server_url }}</span>
</p>
<p v-if="linkedAs" class="mt-0.5 text-xs text-neutral-400">as {{ linkedAs }}</p>
<p class="mt-1 text-xs text-neutral-400">
Last synced {{ fmt(status?.last_sync_at ?? null) }}
<span v-if="pending"> · unsent changes on this device</span>
</p>
</div>
<div class="flex shrink-0 gap-2">
<BaseButton :loading="syncing" @click="syncNow">Sync now</BaseButton>
<BaseButton variant="ghost" @click="disconnect">Disconnect</BaseButton>
</div>
</div>
<p v-if="lastResult && !syncError" class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
{{ lastResult }}
</p>
<p v-if="degraded.length" class="mt-3 text-xs text-amber-600 dark:text-amber-400">
This server doesn't support: {{ degraded.join(", ") }}. Everything else syncs normally.
</p>
<p v-if="syncError" class="mt-3 text-sm text-red-600 dark:text-red-400">{{ syncError }}</p>
</section>
<p class="text-xs text-neutral-400">
Your notes live on this device either way syncing just keeps a server copy in step, so
other devices can catch up.
</p>
</template>
<!-- Not linked: the normal resting state, deliberately not framed as a problem -->
<template v-else>
<section
class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
aria-live="polite"
>
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Working offline on this device
</p>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Everything works without a server your notes are stored on this machine. Connect a
ThoughtSync server if you want them to reach your other devices.
</p>
</section>
<form class="flex flex-col gap-4" @submit.prevent="probe ? connect() : runProbe()">
<div class="flex items-end gap-3">
<BaseInput
id="server-url"
v-model="url"
label="Server address"
placeholder="notes.example.com"
autocomplete="url"
class="flex-1"
/>
<BaseButton type="button" variant="ghost" :loading="probing" @click="runProbe">
Check
</BaseButton>
</div>
<p class="-mt-2 text-xs text-neutral-400">
Uses https unless you type http:// yourself.
</p>
<p v-if="probeError" class="text-sm text-red-600 dark:text-red-400">{{ probeError }}</p>
<!-- What answered, BEFORE any credentials are handed over -->
<div
v-if="probe"
class="rounded-xl border p-3 text-sm"
:class="
probe.compatibility.status === 'incompatible'
? 'border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30'
: 'border-neutral-200 dark:border-neutral-800'
"
>
<p class="font-medium text-neutral-800 dark:text-neutral-100">
{{ probe.server.site_name || "ThoughtSync server" }}
<span v-if="probe.server.version" class="text-xs font-normal text-neutral-400">
v{{ probe.server.version }}
</span>
</p>
<p
class="mt-1 text-xs"
:class="
probe.compatibility.status === 'incompatible'
? 'text-red-700 dark:text-red-300'
: 'text-neutral-500 dark:text-neutral-400'
"
>
{{ describe(probe.compatibility) }}
</p>
</div>
<template v-if="probe && probe.compatibility.status !== 'incompatible'">
<fieldset class="flex flex-col gap-3">
<legend class="mb-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
Sign in
</legend>
<div class="flex gap-4 text-sm">
<label class="flex items-center gap-2">
<input v-model="mode" type="radio" value="password" class="accent-brand" />
Email and password
</label>
<label class="flex items-center gap-2">
<input v-model="mode" type="radio" value="token" class="accent-brand" />
Paste a device token
</label>
</div>
<template v-if="mode === 'password'">
<BaseInput
id="sync-email"
v-model="email"
label="Email"
type="email"
autocomplete="username"
/>
<BaseInput
id="sync-password"
v-model="password"
label="Password"
type="password"
autocomplete="current-password"
/>
</template>
<template v-else>
<BaseInput
id="sync-token"
v-model="token"
label="Device token"
placeholder="Paste the token from Account → Linked devices"
/>
</template>
<BaseInput
id="sync-device-name"
v-model="deviceName"
label="Name for this device (optional)"
placeholder="e.g. My laptop"
/>
</fieldset>
<p v-if="linkError" class="text-sm text-red-600 dark:text-red-400">{{ linkError }}</p>
<div>
<BaseButton type="submit" :loading="linking" :disabled="!canLink">
<Icon name="sync" /> Connect and sync
</BaseButton>
</div>
</template>
</form>
</template>
</div>
</template>