mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 04:13:59 +02:00
Run the desktop's model layer in the browser (#557)
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* The browser host: Yaak in a tab, with no install and nothing running locally.
|
||||
*
|
||||
* The desktop host forwards to a Rust process. This one forwards to a worker
|
||||
* running the same model layer compiled to wasm, over a `MessagePort` instead
|
||||
* of Tauri's IPC. The worker owns the database and is shared by every tab on
|
||||
* the origin, so two tabs stay coherent for the same reason two desktop windows
|
||||
* do: one process holds the data and pushes every write to all of them.
|
||||
*
|
||||
* What a page genuinely cannot do is not faked. There is no file dialog, no
|
||||
* second window, no clipboard read without a prompt, and — in this slice — no
|
||||
* sending. Those report false through `capabilities` and refuse with a reason
|
||||
* if called anyway, so a missing feature shows up as a disabled control or a
|
||||
* toast that explains itself, never as a silent no-op.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DragDropEvent,
|
||||
OsType,
|
||||
Platform,
|
||||
PlatformCapabilities,
|
||||
PlatformWindow,
|
||||
RpcPayload,
|
||||
RpcStreamHandle,
|
||||
Unsubscribe,
|
||||
} from "../types";
|
||||
import { commandSupport, runCommand } from "./commands";
|
||||
import { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
import { requestPersistence } from "./storage";
|
||||
|
||||
/** What this host can do, reported honestly. */
|
||||
function capabilitiesFor(): PlatformCapabilities {
|
||||
return {
|
||||
grpc: false,
|
||||
websocket: false,
|
||||
git: false,
|
||||
sync: false,
|
||||
// Certificates and proxies are decided by whoever puts the bytes on the
|
||||
// wire. Nothing in the browser does yet.
|
||||
tlsOptions: false,
|
||||
// The jar can be edited and stored here; only filling it needs the sender.
|
||||
cookieJar: true,
|
||||
localFiles: false,
|
||||
timeline: false,
|
||||
// Whether the host can put a *second window* on this data on demand — what
|
||||
// `cmd_new_child_window` does for Settings and workspace switching. A tab
|
||||
// can't, so those open in place instead. This is not a claim that nothing
|
||||
// else is looking: other tabs may well be open on the same worker, and it
|
||||
// pushes every write to all of them regardless of this flag.
|
||||
multiWindow: false,
|
||||
plugins: false,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
// Reading needs a permission prompt at first paint, which is a bad ask for
|
||||
// an app people paste bearer tokens into. Pasting still works everywhere.
|
||||
clipboardRead: false,
|
||||
systemFonts: false,
|
||||
license: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Match `@tauri-apps/plugin-os` spellings so layout code needs no new branch. */
|
||||
function detectOsType(): OsType {
|
||||
const ua = navigator.userAgent;
|
||||
if (/Mac|iPhone|iPad|iPod/.test(ua)) return "macos";
|
||||
if (/Win/.test(ua)) return "windows";
|
||||
if (/Android/.test(ua)) return "android";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
/**
|
||||
* The Tauri host-plugin commands, which ride outside the RPC envelope.
|
||||
*
|
||||
* `set_title` has a real browser equivalent. `set_theme` paints the native
|
||||
* window frame behind the webview, which a tab has neither of. The license and
|
||||
* font plugins answer with "nothing", which is true and keeps the settings
|
||||
* screens rendering instead of erroring.
|
||||
*/
|
||||
async function hostPluginCommand<T>(cmd: string, payload?: RpcPayload): Promise<T> {
|
||||
switch (cmd) {
|
||||
case "plugin:yaak-mac-window|set_title": {
|
||||
const title = payload?.title;
|
||||
document.title = typeof title === "string" ? title : "Yaak";
|
||||
return undefined as T;
|
||||
}
|
||||
case "plugin:yaak-mac-window|set_theme":
|
||||
return undefined as T;
|
||||
case "plugin:yaak-fonts|list":
|
||||
// Enumerating installed fonts is a fingerprinting surface browsers don't
|
||||
// offer. The pickers fall back to their bundled families.
|
||||
return { editorFonts: [], uiFonts: [] } as T;
|
||||
default:
|
||||
throw unsupported(cmd, `\`${cmd}\` isn't available when Yaak runs in a browser`);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(db: WorkerConnection): PlatformWindow {
|
||||
const noop = async () => {};
|
||||
|
||||
return {
|
||||
// Stands in for the desktop's window label: the identity model writes carry
|
||||
// so a tab can tell its own echo from another tab's write.
|
||||
label: db.label,
|
||||
|
||||
// A tab manages its own frame. These exist because the interface names
|
||||
// them; the UI only reaches for them behind `multiWindow`.
|
||||
show: noop,
|
||||
close: noop,
|
||||
minimize: noop,
|
||||
maximize: noop,
|
||||
unmaximize: noop,
|
||||
isMaximized: async () => false,
|
||||
isFullscreen: async () => document.fullscreenElement != null,
|
||||
setZoom: noop,
|
||||
|
||||
// Null means "no opinion, let CSS decide". The desktop returns a real value
|
||||
// because applying a theme forces the window appearance and poisons the
|
||||
// media query; nothing does that here, so `prefers-color-scheme` is the
|
||||
// honest answer and the theme package already falls back to it.
|
||||
theme: async () => null,
|
||||
|
||||
onThemeChanged(callback) {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const listener = () => callback(media.matches ? "dark" : "light");
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
},
|
||||
|
||||
onFocusChanged(callback) {
|
||||
const onFocus = () => callback(true);
|
||||
const onBlur = () => callback(false);
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("blur", onBlur);
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
};
|
||||
},
|
||||
|
||||
// Native drag-and-drop reports OS paths, which a page never sees. The DOM's
|
||||
// own drag events are a different thing, and the components that need those
|
||||
// use them directly.
|
||||
onDragDrop(_callback: (event: DragDropEvent) => void): Unsubscribe {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWebPlatform(): Platform {
|
||||
const db = new WorkerConnection();
|
||||
const capabilities = capabilitiesFor();
|
||||
|
||||
// Without this, IndexedDB is best-effort storage and a browser reclaiming
|
||||
// space may drop someone's workspaces. Asking is all we can do, and there is
|
||||
// nothing useful to do about a refusal.
|
||||
void requestPersistence();
|
||||
|
||||
// Enough to answer "what does this host actually do?" from the console
|
||||
// without reading the source.
|
||||
(window as unknown as Record<string, unknown>).__YAAK_WEB__ = {
|
||||
label: db.label,
|
||||
capabilities,
|
||||
commands: commandSupport,
|
||||
};
|
||||
|
||||
return {
|
||||
capabilities,
|
||||
window: createWindow(db),
|
||||
|
||||
clipboard: {
|
||||
writeText: (text) => navigator.clipboard.writeText(text),
|
||||
readText: async () => {
|
||||
throw unsupported("clipboard.readText", "Paste instead — Yaak in a browser can't read the clipboard on its own", "clipboardRead");
|
||||
},
|
||||
clear: async () => {
|
||||
throw unsupported("clipboard.clear", "Yaak in a browser can't modify the clipboard", "clipboardRead");
|
||||
},
|
||||
},
|
||||
|
||||
// Returning null rather than throwing: null is what a cancelled dialog
|
||||
// returns, which every caller already handles.
|
||||
dialog: {
|
||||
open: (async () => null) as Platform["dialog"]["open"],
|
||||
save: async () => null,
|
||||
},
|
||||
|
||||
files: {
|
||||
readDir: async () => {
|
||||
throw unsupported("files.readDir", "A browser tab can't browse your filesystem", "localFiles");
|
||||
},
|
||||
readText: async () => {
|
||||
throw unsupported("files.readText", "A browser tab can't read local files", "localFiles");
|
||||
},
|
||||
// No filesystem here, so a path is just a string this host echoes back.
|
||||
url: (path) => path,
|
||||
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
||||
resolveResource: async (path) => path,
|
||||
},
|
||||
|
||||
/**
|
||||
* Bodies live in the worker's blob database, addressed by the id they were
|
||||
* stored under. A page hands over an id and never a location, which is what
|
||||
* keeps it from naming bytes the app never wrote.
|
||||
*/
|
||||
blobs: {
|
||||
read: (id) => db.blobGet(id),
|
||||
|
||||
async url(id) {
|
||||
const bytes = await db.blobGet(id);
|
||||
// An object URL, the tab's equivalent of Tauri's `convertFileSrc`. The
|
||||
// caller keys a query on it and drops it on the next response, so it is
|
||||
// left to be reclaimed when the document goes rather than revoked here
|
||||
// while an <img> may still be loading it.
|
||||
return bytes == null ? null : URL.createObjectURL(new Blob([bytes]));
|
||||
},
|
||||
},
|
||||
|
||||
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
|
||||
// `plugin:` commands are Tauri host plugins, not engine commands, and
|
||||
// never reached the router even on the desktop.
|
||||
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
|
||||
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
|
||||
},
|
||||
|
||||
async rpcStream<T, M>(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
onMessage: (message: M) => void,
|
||||
): Promise<RpcStreamHandle<T>> {
|
||||
// Same shape as the desktop — subscribe first, then dispatch — so that a
|
||||
// command which grows the ability to stream here needs no caller changes.
|
||||
const streamId = crypto.randomUUID();
|
||||
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
|
||||
try {
|
||||
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
|
||||
return { result, unlisten };
|
||||
} catch (err) {
|
||||
unlisten();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe {
|
||||
return db.listen(event, (payload) => callback(payload as T));
|
||||
},
|
||||
|
||||
// Local only. Every emitter in the app is replying to something this tab is
|
||||
// doing — a plugin round trip, a stream teardown — and telling other tabs
|
||||
// about it would answer a question they never asked.
|
||||
emit: async (event, payload) => db.deliver(event, payload),
|
||||
|
||||
openUrl: async (url) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
|
||||
revealItemInDir: async () => {
|
||||
throw unsupported("revealItemInDir", "A browser tab can't open your file manager", "localFiles");
|
||||
},
|
||||
|
||||
osType: detectOsType,
|
||||
appIdentifier: async () => "app.yaak.web",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user