// 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: (cmd: string, args?: Record) => Promise }; } 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(cmd: string, args?: Record): Promise { const tauri = window.__TAURI__; if (!tauri) return Promise.reject(new Error("Not running in the desktop app.")); return tauri.core.invoke(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("integration_status"), 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; 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("sync_probe", { url }), link: (input: LinkInput) => invoke("sync_link", { input }), /** Stops syncing AND revokes this device's token server-side; see UnlinkResult. */ 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"), }; // --- 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("update_channel_get"), setChannel: (channel: UpdateChannel) => invoke("update_channel_set", { channel }), /** Ask the feed what's out there. Never installs anything. */ check: () => invoke("update_check"), /** * Download, verify, apply, relaunch. Resolves only on failure — a success * restarts the app out from under the caller. */ install: () => invoke("update_install"), };