Add a typed platform package to decouple the frontend from Tauri (#539)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-08-14 12:44:01 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 0068be9ffc
commit 2383f06e71
107 changed files with 954 additions and 505 deletions
+14
View File
@@ -0,0 +1,14 @@
import { platform } from "./registry";
import type { CapabilityName } from "./types";
/**
* Whether the current host supports a feature.
*
* A hook so components can gate on it directly, and so this can become reactive
* later — a browser tab gains and loses capabilities when the local bridge comes
* and goes. Capabilities are fixed for the life of the desktop app, so today it
* is a plain read.
*/
export function useCapability(name: CapabilityName): boolean {
return platform.capabilities[name];
}
+15
View File
@@ -0,0 +1,15 @@
import { setPlatform } from "./registry";
import { createTauriPlatform } from "./tauri";
// Desktop is the only host today, so it is installed unconditionally and
// synchronously — several modules call commands while the module graph is still
// evaluating, so there is no later moment to do this in.
//
// This line is the swap point. A browser build selects its own host here, and
// because nothing else in the app imports a host directly, that is the whole
// change.
setPlatform(createTauriPlatform());
export * from "./capabilities";
export { platform, setPlatform } from "./registry";
export * from "./types";
+60
View File
@@ -0,0 +1,60 @@
import type { Platform } from "./types";
let installed: Platform | null = null;
/**
* Install the host implementation. Called once at import time by this package's
* entry point, before any consumer's module body runs.
*
* It stays swappable at runtime because a browser build has to choose between
* talking to a local bridge and running in-page, and it can only find out which
* after it has tried to reach the bridge.
*/
export function setPlatform(next: Platform): void {
installed = next;
}
function host(): Platform {
if (installed == null) {
throw new Error("No platform installed. Import @yaakapp-internal/platform before using it.");
}
return installed;
}
/**
* The host, for everyone else to use.
*
* This is a fixed object that forwards to whatever is installed, so modules can
* import it at any time — including at module scope, which several boot-time
* modules do — without capturing a stale implementation.
*
* A host that has to connect before it can work (a WebSocket to the bridge)
* buffers inside its own `rpc`; nothing here waits on a connection, because the
* app's boot sequence calls commands while the module graph is still evaluating
* and cannot be made to wait.
*/
export const platform: Platform = {
get capabilities() {
return host().capabilities;
},
get window() {
return host().window;
},
get clipboard() {
return host().clipboard;
},
get dialog() {
return host().dialog;
},
get files() {
return host().files;
},
rpc: (cmd, payload) => host().rpc(cmd, payload),
rpcStream: (cmd, payload, onMessage) => host().rpcStream(cmd, payload, onMessage),
listen: (event, callback) => host().listen(event, callback),
emit: (event, payload) => host().emit(event, payload),
openUrl: (url) => host().openUrl(url),
revealItemInDir: (path) => host().revealItemInDir(path),
osType: () => host().osType(),
appIdentifier: () => host().appIdentifier(),
};
+155
View File
@@ -0,0 +1,155 @@
import { getIdentifier } from "@tauri-apps/api/app";
import { Channel, convertFileSrc, invoke } from "@tauri-apps/api/core";
import { emit as tauriEmit, listen as tauriListen } from "@tauri-apps/api/event";
import { basename, resolveResource } from "@tauri-apps/api/path";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { open, save } from "@tauri-apps/plugin-dialog";
import { readDir, readFile } from "@tauri-apps/plugin-fs";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import { type as osType } from "@tauri-apps/plugin-os";
import type {
DragDropEvent,
OpenDialogOptions,
Platform,
PlatformCapabilities,
PlatformWindow,
RpcPayload,
Unsubscribe,
} from "../types";
/**
* The desktop host: the yaak-rpc envelope carried by Tauri's `invoke` and
* window events.
*
* Commands still arrive as their own `invoke` names rather than one `rpc`
* command, because the Rust side has not moved onto `RpcRouter` yet. When it
* does, only `rpc` below changes — `invoke("rpc", { cmd, payload })`, the way
* the proxy app already does it — and no call site notices.
*/
/**
* Tauri hands back an unsubscribe function asynchronously; the platform hands
* back one immediately, so callers can unsubscribe from a React cleanup without
* awaiting. Unsubscribing before the subscription lands is honoured once it does.
*/
function toSyncUnsubscribe(pending: Promise<Unsubscribe>): Unsubscribe {
let unsubscribe: Unsubscribe | null = null;
let cancelled = false;
pending
.then((fn) => {
if (cancelled) fn();
else unsubscribe = fn;
})
.catch(console.error);
return () => {
cancelled = true;
unsubscribe?.();
unsubscribe = null;
};
}
const ALL_CAPABILITIES: PlatformCapabilities = {
grpc: true,
websocket: true,
git: true,
sync: true,
tlsOptions: true,
cookieJar: true,
localFiles: true,
timeline: true,
multiWindow: true,
plugins: true,
encryption: true,
updater: true,
clipboardRead: true,
systemFonts: true,
license: true,
};
function createWindow(): PlatformWindow {
const webview = getCurrentWebviewWindow();
return {
label: webview.label,
show: () => webview.show(),
close: () => webview.close(),
minimize: () => webview.minimize(),
maximize: () => webview.maximize(),
unmaximize: () => webview.unmaximize(),
isMaximized: () => webview.isMaximized(),
isFullscreen: () => webview.isFullscreen(),
setZoom: (scale) => webview.setZoom(scale),
theme: () => webview.theme(),
onThemeChanged: (callback) =>
toSyncUnsubscribe(webview.onThemeChanged((e) => callback(e.payload))),
onFocusChanged: (callback) =>
toSyncUnsubscribe(webview.onFocusChanged((e) => callback(e.payload))),
onDragDrop: (callback) =>
toSyncUnsubscribe(webview.onDragDropEvent((e) => callback(e.payload as DragDropEvent))),
};
}
export function createTauriPlatform(): Platform {
const window = createWindow();
return {
capabilities: ALL_CAPABILITIES,
window,
clipboard: {
writeText: (text) => writeText(text),
readText: () => readText(),
clear: () => clear(),
},
dialog: {
// Overloaded on the interface; one implementation covers both shapes.
open: ((options?: OpenDialogOptions) => open(options)) as Platform["dialog"]["open"],
save: (options) => save(options ?? {}),
},
files: {
readFile: (path) => readFile(path),
readDir: (path) => readDir(path),
url: (path) => convertFileSrc(path),
basename: (path) => basename(path),
resolveResource: (path) => resolveResource(path),
},
async rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
try {
return await invoke<T>(cmd, payload);
} catch (err) {
console.warn("Platform command error", cmd, err);
throw err;
}
},
rpcStream<T, M>(cmd: string, payload: RpcPayload, onMessage: (message: M) => void): Promise<T> {
const channel = new Channel<M>();
channel.onmessage = onMessage;
return invoke<T>(cmd, { ...payload, channel });
},
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe {
return toSyncUnsubscribe(
tauriListen<T>(event, (e) => callback(e.payload), {
// Receives events broadcast to every window as well as ones addressed
// to this one, which is how the backend sends both kinds today.
target: { kind: "Window", label: window.label },
}),
);
},
emit: (event, payload) => tauriEmit(event, payload),
openUrl: (url) => openUrl(url),
revealItemInDir: (path) => revealItemInDir(path),
osType: () => osType(),
appIdentifier: () => getIdentifier(),
};
}
+226
View File
@@ -0,0 +1,226 @@
/**
* The complete surface the app is allowed to use to reach its host.
*
* Everything the UI needs from outside the page goes through this interface, so
* that a host other than Tauri (a browser tab talking to the local bridge, a
* self-hosted or hosted server) can be added by writing one more implementation
* instead of touching call sites. Nothing outside `src/tauri` may import
* `@tauri-apps/*` — that is the whole point of the package.
*
* Commands and events are the yaak-rpc envelope: a command name plus a JSON
* payload in, a JSON payload out, and named events pushed the other way. The
* Tauri host puts that envelope inside `invoke`; a WebSocket host will put it on
* the wire unchanged.
*/
/** Cancels a subscription. Safe to call more than once. */
export type Unsubscribe = () => void;
/** Matches the values `@tauri-apps/plugin-os` reports so hosts agree on spelling. */
export type OsType =
| "android"
| "dragonfly"
| "freebsd"
| "ios"
| "linux"
| "macos"
| "netbsd"
| "openbsd"
| "solaris"
| "windows";
export type PlatformAppearance = "light" | "dark";
/** Command arguments. Serialized to JSON, so only JSON values belong here. */
export type RpcPayload = Record<string, unknown>;
export interface DialogFilter {
name: string;
extensions: string[];
}
export interface OpenDialogOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: DialogFilter[];
}
export interface SaveDialogOptions {
title?: string;
defaultPath?: string;
filters?: DialogFilter[];
}
export interface DirEntry {
name: string;
isDirectory: boolean;
isFile: boolean;
isSymlink: boolean;
}
export interface DragDropPosition {
x: number;
y: number;
}
/**
* A native drag-and-drop over the window. Distinct from the DOM's drag events:
* the host reports window coordinates, not a DOM target, so listeners hit-test
* against their own bounding box.
*/
export type DragDropEvent =
| { type: "enter"; paths: string[]; position: DragDropPosition }
| { type: "over"; position: DragDropPosition }
| { type: "drop"; paths: string[]; position: DragDropPosition }
| { type: "leave" };
/**
* The window (desktop) or tab (browser) this UI is running in.
*
* Every method a host must provide is named here on purpose. The build spike
* replaced the Tauri window object wholesale and a single missing method
* (`onDragDropEvent`) threw mid-render with nothing to catch it at compile time;
* an explicit interface makes that class of failure a type error instead.
*/
export interface PlatformWindow {
/**
* Identity of this window among all the app's windows.
*
* Model writes carry the label of the window that made them so the others can
* tell an echo of their own write from someone else's. A browser host can
* satisfy this with a per-tab id and a BroadcastChannel.
*/
readonly label: string;
show(): Promise<void>;
close(): Promise<void>;
minimize(): Promise<void>;
maximize(): Promise<void>;
unmaximize(): Promise<void>;
isMaximized(): Promise<boolean>;
isFullscreen(): Promise<boolean>;
/** Scale the whole window's contents. 1 is unscaled. */
setZoom(scale: number): Promise<void>;
/** The host's current appearance, or null if it has no opinion and CSS should decide. */
theme(): Promise<PlatformAppearance | null>;
onThemeChanged(callback: (appearance: PlatformAppearance) => void): Unsubscribe;
onFocusChanged(callback: (focused: boolean) => void): Unsubscribe;
onDragDrop(callback: (event: DragDropEvent) => void): Unsubscribe;
}
export interface PlatformClipboard {
writeText(text: string): Promise<void>;
readText(): Promise<string>;
clear(): Promise<void>;
}
export interface PlatformDialog {
open(options: OpenDialogOptions & { multiple: true }): Promise<string[] | null>;
open(options?: OpenDialogOptions): Promise<string | null>;
save(options?: SaveDialogOptions): Promise<string | null>;
}
/**
* File-ish operations, keyed by paths the backend handed us.
*
* A path here is an opaque handle, not something to parse or construct: the UI
* only ever passes one back to `readFile` or `url`. A host without a filesystem
* can mint handles of its own (a blob id, a URL) and stay compatible.
*/
export interface PlatformFiles {
readFile(path: string): Promise<Uint8Array<ArrayBuffer>>;
readDir(path: string): Promise<DirEntry[]>;
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
url(path: string): string;
basename(path: string): Promise<string>;
/** Resolve a path bundled with the app itself, rather than one from the backend. */
resolveResource(path: string): Promise<string>;
}
export interface Platform {
readonly capabilities: PlatformCapabilities;
readonly window: PlatformWindow;
readonly clipboard: PlatformClipboard;
readonly dialog: PlatformDialog;
readonly files: PlatformFiles;
/** Call a backend command and await its result. */
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T>;
/**
* Call a command that streams messages back before it resolves.
*
* The host passes the stream to the backend under the payload's `channel` key,
* which is the shape the existing sync and git watchers already expect.
*/
rpcStream<T, M>(cmd: string, payload: RpcPayload, onMessage: (message: M) => void): Promise<T>;
/** Subscribe to a backend event addressed to this window. */
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe;
/** Send an event to the backend. Used for replies and for long-lived streams. */
emit(event: string, payload?: unknown): Promise<void>;
openUrl(url: string): Promise<void>;
revealItemInDir(path: string): Promise<void>;
/**
* Which OS the UI is running on. Synchronous because layout decisions
* (traffic-light padding, modifier key labels) are made during first render.
*/
osType(): OsType;
/** The host application's identifier, eg. `app.yaak.desktop`. */
appIdentifier(): Promise<string>;
}
/**
* What this host can actually do.
*
* Read these instead of testing for a platform: "does this host have a cookie
* jar" is the real question, and it stays answerable when there are three hosts.
* The Tauri host hardcodes every capability on; Rust hosts will report theirs
* from the cargo features they were built with.
*/
export interface PlatformCapabilities {
/** Send gRPC requests. Needs HTTP/2 trailers, so it needs a real backend. */
grpc: boolean;
/** Send WebSocket requests with custom headers and auth. */
websocket: boolean;
/** Git-backed workspaces. */
git: boolean;
/** Two-way sync between a workspace and a directory of files. */
sync: boolean;
/** Client certificates, custom CAs, and disabling certificate validation. */
tlsOptions: boolean;
/** A cookie jar the user can read and edit. */
cookieJar: boolean;
/** Paths the backend can read: file bodies, proto files, sync directories. */
localFiles: boolean;
/** Per-request timing and connection detail. */
timeline: boolean;
/** More than one window or tab on the same data. */
multiWindow: boolean;
/** The plugin runtime. */
plugins: boolean;
/** Workspace encryption backed by a key the host keeps. */
encryption: boolean;
/** In-app update checks and installs. */
updater: boolean;
/** Reading the clipboard without the user pasting first. */
clipboardRead: boolean;
/** Enumerating the fonts installed on the machine. */
systemFonts: boolean;
/** Commercial licence activation. */
license: boolean;
}
export type CapabilityName = keyof PlatformCapabilities;