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
`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
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"),
|
|
};
|