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
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:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<SyncOutco
|
||||
pull.clobbered_dirty
|
||||
);
|
||||
}
|
||||
Ok(SyncOutcome { push, pull })
|
||||
|
||||
let status = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
// Stamped only here, after BOTH halves succeeded. A timestamp written after a
|
||||
// partial cycle would tell the user they're up to date when they aren't.
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
state::mark_synced(&conn, &now).map_err(|e| e.to_string())?;
|
||||
state::status(&conn).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
Ok(SyncOutcome {
|
||||
push,
|
||||
pull,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct SyncState {
|
||||
pub server_url: Option<String>,
|
||||
pub device_token: Option<String>,
|
||||
pub last_cursor: i64,
|
||||
pub last_sync_at: Option<String>,
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
@@ -37,6 +38,7 @@ pub struct Status {
|
||||
pub linked: bool,
|
||||
pub server_url: Option<String>,
|
||||
pub last_cursor: i64,
|
||||
pub last_sync_at: Option<String>,
|
||||
}
|
||||
|
||||
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<String>) -> Option<String> {
|
||||
|
||||
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
||||
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<String> = 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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>',
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user