Files
thoughtsync/frontend/src/desktop/bridge.ts
T
bvandeusenandClaude Opus 5 2cfe049f9c
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
sync: unlinking a device now revokes its token on the server (issue 2110)
Unlink was local-only. It cleared the server URL, token and cursor from the
device, and left the bearer token valid on the server indefinitely — so someone
who unlinked because the laptop was being sold or handed on believed they had
revoked access when they hadn't.

The blocker was identification, not intent: a token pasted from the web app
never carried a device id, and /api/auth/me describes the user, not the device
row, so DELETE /devices/<id> could only ever have worked for one of the two ways
this app can be linked. DELETE /api/auth/devices/self keys off the token in the
Authorization header instead, which the caller always holds — one route that
works for both paths, owner-scoped like the rest, and no local schema change.

Unlinking is never blocked on the network. Wanting to stop syncing is a local
decision, so the revoke is attempted first, its outcome carried back, and the
link cleared either way. When the token survives — server unreachable, or older
than the route — the Sync screen says so in place, with where to revoke it. A
toast would have been the wrong shape for that: it disappears, and this is
exactly what someone returns to the screen to check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:31:06 -04:00

195 lines
6.1 KiB
TypeScript

// Bridge to the Tauri desktop core. On the web build there is no Tauri runtime, so
// isDesktop() is false and none of these calls run. Uses the global injected by
// `withGlobalTauri` (tauri.conf.json), so the shared frontend needs no
// @tauri-apps/api dependency and the web bundle is unaffected.
interface TauriGlobal {
core: { invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> };
}
declare global {
interface Window {
__TAURI__?: TauriGlobal;
}
}
export interface IntegrationStatus {
is_appimage: boolean;
is_integrated: boolean;
appimage_path: string | null;
}
/** True when running inside the Tauri desktop shell (vs. the web build). */
export function isDesktop(): boolean {
return typeof window !== "undefined" && !!window.__TAURI__;
}
export function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const tauri = window.__TAURI__;
if (!tauri) return Promise.reject(new Error("Not running in the desktop app."));
return tauri.core.invoke<T>(cmd, args).catch((err: unknown) => {
// Every failed command names itself in the log — so a broken "basic function"
// in some environment is diagnosable. Skip log_event to avoid recursion.
if (cmd !== "log_event") logEvent("error", `invoke '${cmd}' failed: ${String(err)}`);
throw err;
});
}
/** Route a frontend message into the desktop log (stdout + file). No-op on web. */
export function logEvent(level: "info" | "warn" | "error" | "debug", message: string): void {
if (!isDesktop()) return;
void invoke("log_event", { level, message }).catch(() => {});
}
// Desktop (Linux AppImage) self-integration: add/remove an applications-menu entry.
export const desktop = {
integrationStatus: () => invoke<IntegrationStatus>("integration_status"),
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;
blobs_downloaded: number;
/** Attachments whose bytes didn't arrive. Retried next sync, never fatal. */
blobs_failed: number;
}
export interface SyncOutcome {
push: PushSummary;
pull: PullSummary;
status: SyncStatus;
}
/**
* What happened to this device's token on the SERVER when unlinking. Unlinking
* always succeeds locally, so this is the only part that can disappoint — and the
* user who unlinked to retire a machine is exactly who needs to be told.
*/
export type RevokeOutcome =
| { status: "revoked" }
| { status: "unsupported" }
| { status: "failed"; reason: string }
| { status: "skipped" };
export interface UnlinkResult {
status: SyncStatus;
revoked: RevokeOutcome;
}
/** 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 }),
/** Stops syncing AND revokes this device's token server-side; see UnlinkResult. */
unlink: () => invoke<UnlinkResult>("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"),
};
// --- In-app updates (M10.9) --------------------------------------------------
/** `stable` follows tagged releases; `dev` follows every green build. */
export type UpdateChannel = "stable" | "dev";
export interface UpdateStatus {
channel: UpdateChannel;
current_version: string;
/** The newer version on offer, or null when already up to date. */
available: string | null;
notes: string | null;
/** False when this install can't replace itself — see `blocked_reason`. */
can_install: boolean;
blocked_reason: string | null;
}
export const updates = {
channel: () => invoke<UpdateChannel>("update_channel_get"),
setChannel: (channel: UpdateChannel) => invoke<UpdateChannel>("update_channel_set", { channel }),
/** Ask the feed what's out there. Never installs anything. */
check: () => invoke<UpdateStatus>("update_check"),
/**
* Download, verify, apply, relaunch. Resolves only on failure — a success
* restarts the app out from under the caller.
*/
install: () => invoke<void>("update_install"),
};