Files
thoughtsync/frontend/src/desktop/bridge.ts
T
bvandeusenandClaude Opus 5 10ea15bef0
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Revert the desktop hotkey: a new crate needs a Cargo.lock this machine cannot write
`42e06da` added `tauri-plugin-global-shortcut` to Cargo.toml without updating
Cargo.lock, and every cargo invocation in CI passes `--locked`. Both desktop
jobs failed on the same line before compiling anything:

    error: cannot update the lock file ... because --locked was passed

So this says nothing about whether the code is right — clippy never ran. The
gate did exactly its job.

There is no Rust toolchain on this workstation (rule 10 — CI verifies), and a
lockfile is the one artifact CI is deliberately forbidden to generate. Hand-
writing the entries is not a real option: it needs the exact checksum and the
whole transitive tree, and a wrong checksum fails harder than a missing one.

Reverted rather than left red, because a red `dev` blocks everything behind it
and the Android half of #1899 is green and unaffected at c8318c3. The work is
intact in 42e06da and comes back with `git revert 5e0c...` once the lockfile
exists — nothing here needs rewriting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 09:10:33 -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"),
};