mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 12:24:01 +02:00
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:
co-authored by
Claude Opus 5
parent
0068be9ffc
commit
2383f06e71
@@ -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];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user