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
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>
195 lines
6.1 KiB
TypeScript
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"),
|
|
};
|