mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-22 11:23:59 +02:00
The web build bound Cmd/Ctrl +, - and 0 and then did nothing with them, so zoom appeared broken. Those keys are the browser's own page zoom, which scales the whole page and remembers it per site. Adds an interfaceZoom capability: true on desktop, where the host zooms the webview, false in a browser. When false the app binds nothing and the hotkeys screen drops the three rows it can't configure.
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
/**
|
|
* 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,
|
|
// The browser draws the frame around the page. There are no traffic lights
|
|
// to leave room for and no window controls to draw.
|
|
windowChrome: false,
|
|
// The browser already zooms the page, on the same keys, and remembers it
|
|
// per site. The app stays out of the way.
|
|
interfaceZoom: 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",
|
|
};
|
|
}
|