mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 12:24:01 +02:00
Add the Yaak Bridge so a browser tab can run the real engine
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
93001e3da7
commit
e294e6bcef
@@ -0,0 +1,211 @@
|
||||
import type { Unsubscribe } from "../types";
|
||||
|
||||
/**
|
||||
* The wire to the Yaak Bridge: one `POST /rpc` per command, one WebSocket for
|
||||
* events in both directions.
|
||||
*
|
||||
* The hard requirement this file exists to satisfy: the connection is opened
|
||||
* asynchronously, but the host that uses it must be constructible
|
||||
* *synchronously*. Boot-time modules call commands while the module graph is
|
||||
* still evaluating (`lib/appInfo.ts` top-level-awaits one), so there is no
|
||||
* later moment to install a host, and a registry that waited for a socket would
|
||||
* deadlock. So every call made before the connection opens is queued here and
|
||||
* flushed when it does. The app's own top-level await then doubles as the
|
||||
* connection gate: nothing renders until the first command has answered, which
|
||||
* means it has answered over a live connection.
|
||||
*/
|
||||
|
||||
interface EventFrame {
|
||||
event: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface BridgeInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
capabilities: Record<string, boolean>;
|
||||
commands: string[];
|
||||
}
|
||||
|
||||
/** How long to wait before retrying a dropped connection, and the ceiling. */
|
||||
const RECONNECT_BASE_MS = 250;
|
||||
const RECONNECT_MAX_MS = 5000;
|
||||
|
||||
export class BridgeConnection {
|
||||
readonly baseUrl: string;
|
||||
readonly label: string;
|
||||
/**
|
||||
* Null when the user hasn't supplied one yet. The connection then never
|
||||
* opens, so every call queues forever — which is exactly what the connect
|
||||
* screen wants, and means "waiting for a token" and "waiting for the socket"
|
||||
* are the same code path rather than two.
|
||||
*/
|
||||
private readonly token: string | null;
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private connected = false;
|
||||
private reconnectDelay = RECONNECT_BASE_MS;
|
||||
|
||||
/** Frames the page tried to send before the socket opened. */
|
||||
private outboundQueue: EventFrame[] = [];
|
||||
/** Resolvers for anything awaiting the first successful connection. */
|
||||
private readyWaiters: Array<() => void> = [];
|
||||
private listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
|
||||
info: BridgeInfo | null = null;
|
||||
|
||||
constructor(baseUrl: string, token: string | null, label: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
this.token = token;
|
||||
this.label = label;
|
||||
if (token != null) this.openSocket();
|
||||
}
|
||||
|
||||
get hasToken(): boolean {
|
||||
return this.token != null;
|
||||
}
|
||||
|
||||
/** Resolves once the events socket is open. */
|
||||
ready(): Promise<void> {
|
||||
if (this.connected) return Promise.resolve();
|
||||
return new Promise((resolve) => this.readyWaiters.push(resolve));
|
||||
}
|
||||
|
||||
/** A URL on the bridge with the token attached, for the browser to fetch directly. */
|
||||
url(path: string): string {
|
||||
const url = new URL(this.baseUrl + path);
|
||||
url.searchParams.set("token", this.token ?? "");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async fetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${this.token ?? ""}`);
|
||||
return fetch(this.baseUrl + path, { ...init, headers });
|
||||
}
|
||||
|
||||
async loadInfo(): Promise<BridgeInfo> {
|
||||
const res = await this.fetch("/bridge/info");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Bridge rejected the connection (${res.status}). Is the token correct?`);
|
||||
}
|
||||
this.info = (await res.json()) as BridgeInfo;
|
||||
return this.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command and await its result.
|
||||
*
|
||||
* Waits for the connection first, so a command issued during module
|
||||
* evaluation queues instead of failing. Errors are carried inside the
|
||||
* envelope and rethrown here, so callers see the backend's own message —
|
||||
* matching what Tauri's `invoke` does with a rejected command.
|
||||
*/
|
||||
async rpc<T>(cmd: string, payload: Record<string, unknown> = {}): Promise<T> {
|
||||
await this.ready();
|
||||
|
||||
const res = await this.fetch("/rpc", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: crypto.randomUUID(), cmd, payload }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Bridge request failed (${res.status})`);
|
||||
}
|
||||
|
||||
const body = (await res.json()) as
|
||||
| { type: "Success"; id: string; payload: T }
|
||||
| { type: "Error"; id: string; error: string };
|
||||
|
||||
if (body.type === "Error") {
|
||||
throw new Error(body.error);
|
||||
}
|
||||
return body.payload;
|
||||
}
|
||||
|
||||
listen(event: string, callback: (payload: unknown) => void): Unsubscribe {
|
||||
let handlers = this.listeners.get(event);
|
||||
if (handlers == null) {
|
||||
handlers = new Set();
|
||||
this.listeners.set(event, handlers);
|
||||
}
|
||||
handlers.add(callback);
|
||||
|
||||
// Synchronous, because callers unsubscribe from React cleanups.
|
||||
return () => {
|
||||
const current = this.listeners.get(event);
|
||||
if (current == null) return;
|
||||
current.delete(callback);
|
||||
if (current.size === 0) this.listeners.delete(event);
|
||||
};
|
||||
}
|
||||
|
||||
emit(event: string, payload: unknown): void {
|
||||
const frame: EventFrame = { event, payload };
|
||||
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
} else {
|
||||
this.outboundQueue.push(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tell the bridge who and where we are — what a desktop window's URL says. */
|
||||
attach(): void {
|
||||
this.emit("bridge_attach", { label: this.label, url: window.location.href });
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
const wsUrl = new URL(this.baseUrl.replace(/^http/, "ws") + "/events");
|
||||
wsUrl.searchParams.set("token", this.token ?? "");
|
||||
|
||||
const socket = new WebSocket(wsUrl.toString());
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
this.connected = true;
|
||||
this.reconnectDelay = RECONNECT_BASE_MS;
|
||||
this.attach();
|
||||
|
||||
for (const frame of this.outboundQueue.splice(0)) {
|
||||
socket.send(JSON.stringify(frame));
|
||||
}
|
||||
for (const resolve of this.readyWaiters.splice(0)) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
let frame: EventFrame;
|
||||
try {
|
||||
frame = JSON.parse(String(message.data)) as EventFrame;
|
||||
} catch {
|
||||
console.warn("Bridge sent a malformed event frame");
|
||||
return;
|
||||
}
|
||||
// Deliver the payload directly, not wrapped in Tauri's `{ payload }`.
|
||||
for (const handler of this.listeners.get(frame.event) ?? []) {
|
||||
try {
|
||||
handler(frame.payload);
|
||||
} catch (err) {
|
||||
console.error("Bridge event handler threw", frame.event, err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
this.connected = false;
|
||||
this.socket = null;
|
||||
// The server closes the socket when a tab falls too far behind to be
|
||||
// consistent, so a reconnect has to re-read the workspace rather than
|
||||
// resume. `bridge_reconnected` is what tells the app to do that.
|
||||
window.setTimeout(() => this.openSocket(), this.reconnectDelay);
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
// `onclose` always follows, and it owns the retry.
|
||||
socket.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type {
|
||||
DragDropEvent,
|
||||
OsType,
|
||||
Platform,
|
||||
PlatformCapabilities,
|
||||
PlatformWindow,
|
||||
RpcPayload,
|
||||
RpcStreamHandle,
|
||||
Unsubscribe,
|
||||
} from "../types";
|
||||
import { BridgeConnection } from "./connection";
|
||||
|
||||
/**
|
||||
* The browser host: the Yaak UI in a tab, with the real engine running in the
|
||||
* Yaak Bridge next to it.
|
||||
*
|
||||
* Everything the desktop gets from Tauri comes over one HTTP connection
|
||||
* instead. The parts a page genuinely cannot do — a native file dialog, a
|
||||
* second window, reading the clipboard unprompted — are not faked. They report
|
||||
* false through `capabilities` and throw if called anyway, so a missing feature
|
||||
* surfaces as a disabled control rather than a silent no-op.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Until the bridge answers, assume nothing works.
|
||||
*
|
||||
* These are replaced wholesale by the server's own report as soon as
|
||||
* `/bridge/info` returns, which happens before the app's first render — the
|
||||
* boot sequence top-level-awaits a command, and that command cannot resolve
|
||||
* before the connection is up. Starting pessimistic means that if that ordering
|
||||
* ever changes, the UI hides a feature it should have shown instead of offering
|
||||
* one that will fail.
|
||||
*/
|
||||
const NO_CAPABILITIES: PlatformCapabilities = {
|
||||
grpc: false,
|
||||
websocket: false,
|
||||
git: false,
|
||||
sync: false,
|
||||
tlsOptions: false,
|
||||
cookieJar: false,
|
||||
localFiles: false,
|
||||
timeline: false,
|
||||
multiWindow: false,
|
||||
plugins: false,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
clipboardRead: false,
|
||||
systemFonts: false,
|
||||
license: false,
|
||||
};
|
||||
|
||||
function unsupported(what: string): Error {
|
||||
return new Error(`${what} is not supported in the browser`);
|
||||
}
|
||||
|
||||
/** Match `@tauri-apps/plugin-os` spellings so layout code needs no new branch. */
|
||||
function detectOsType(): OsType {
|
||||
const platform = navigator.userAgent;
|
||||
if (/Mac|iPhone|iPad|iPod/.test(platform)) return "macos";
|
||||
if (/Win/.test(platform)) return "windows";
|
||||
if (/Android/.test(platform)) return "android";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
/**
|
||||
* A response body path is an opaque handle the backend minted, and the bridge
|
||||
* writes them as `<data dir>/responses/<response id>`. Taking the last segment
|
||||
* turns it back into the id the `/responses/{id}/body` route wants, which keeps
|
||||
* the server from ever being asked for a path chosen by the page.
|
||||
*/
|
||||
function responseIdFromBodyPath(path: string): string {
|
||||
const segments = path.split(/[/\\]/);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer 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 no equivalent of and does
|
||||
* not need. Everything else is a desktop-only feature; rejecting is correct,
|
||||
* and the callers already gate on the matching capability.
|
||||
*/
|
||||
async function handleHostPluginCommand<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;
|
||||
default:
|
||||
throw unsupported(`\`${cmd}\``);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(connection: BridgeConnection): PlatformWindow {
|
||||
const noop = async () => {};
|
||||
|
||||
return {
|
||||
label: connection.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 them
|
||||
// use them directly.
|
||||
onDragDrop(_callback: (event: DragDropEvent) => void): Unsubscribe {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the bridge told where the tab is.
|
||||
*
|
||||
* The desktop reads the workspace, environment, cookie jar and request straight
|
||||
* off the window's URL whenever a plugin asks. The bridge can't, so the tab
|
||||
* pushes it on every navigation. The router uses the History API, which fires
|
||||
* no event of its own on push, hence the wrapping.
|
||||
*/
|
||||
function trackNavigation(connection: BridgeConnection): void {
|
||||
const report = () => connection.attach();
|
||||
|
||||
for (const method of ["pushState", "replaceState"] as const) {
|
||||
const original = history[method];
|
||||
history[method] = function (this: History, ...args: Parameters<History["pushState"]>) {
|
||||
const result = original.apply(this, args);
|
||||
report();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", report);
|
||||
window.addEventListener("hashchange", report);
|
||||
}
|
||||
|
||||
export function createBridgePlatform(baseUrl: string, token: string | null): Platform {
|
||||
const label = `tab_${crypto.randomUUID().slice(0, 8)}`;
|
||||
const connection = new BridgeConnection(baseUrl, token, label);
|
||||
|
||||
// Mutated in place once the bridge reports, because `platform.capabilities`
|
||||
// hands out this object and callers hold the reference.
|
||||
const capabilities: PlatformCapabilities = { ...NO_CAPABILITIES };
|
||||
|
||||
if (connection.hasToken) {
|
||||
void connection
|
||||
.loadInfo()
|
||||
.then((info) => Object.assign(capabilities, info.capabilities))
|
||||
.catch((err) => console.error("Failed to read bridge capabilities", err));
|
||||
}
|
||||
|
||||
trackNavigation(connection);
|
||||
|
||||
// Two host requests the plugin runtime makes that only a page can carry out.
|
||||
connection.listen("bridge_copy_text", (payload) => {
|
||||
const text = (payload as { text?: string } | null)?.text;
|
||||
if (typeof text === "string") void navigator.clipboard.writeText(text);
|
||||
});
|
||||
connection.listen("bridge_open_url", (payload) => {
|
||||
const url = (payload as { url?: string } | null)?.url;
|
||||
if (typeof url === "string") window.open(url, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
const platformWindow = createWindow(connection);
|
||||
|
||||
return {
|
||||
capabilities,
|
||||
window: platformWindow,
|
||||
|
||||
clipboard: {
|
||||
writeText: (text) => navigator.clipboard.writeText(text),
|
||||
// Reading needs a permission prompt the moment the page paints, which is
|
||||
// a bad ask for an app people paste bearer tokens into. `clipboardRead`
|
||||
// is false and the one caller is gated on it.
|
||||
readText: async () => {
|
||||
throw unsupported("Reading the clipboard");
|
||||
},
|
||||
clear: async () => {
|
||||
throw unsupported("Clearing the clipboard");
|
||||
},
|
||||
},
|
||||
|
||||
dialog: {
|
||||
open: (async () => null) as Platform["dialog"]["open"],
|
||||
save: async () => null,
|
||||
},
|
||||
|
||||
files: {
|
||||
async readFile(path) {
|
||||
const res = await connection.fetch(`/responses/${responseIdFromBodyPath(path)}/body`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to read response body (${res.status})`);
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
},
|
||||
|
||||
readDir: async () => {
|
||||
throw unsupported("Browsing the filesystem");
|
||||
},
|
||||
|
||||
// The `<img src>`/`<video src>` equivalent of Tauri's `convertFileSrc`.
|
||||
// The token rides in the query because the browser makes these requests
|
||||
// itself and the page cannot add a header to them.
|
||||
url: (path) => connection.url(`/responses/${responseIdFromBodyPath(path)}/body`),
|
||||
|
||||
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
||||
resolveResource: async (path) => path,
|
||||
},
|
||||
|
||||
rpc: <T,>(cmd: string, payload?: RpcPayload): Promise<T> => {
|
||||
// `plugin:`-prefixed commands are Tauri host plugins, not engine
|
||||
// commands, so they never reach the RpcRouter. Two of them are window
|
||||
// chrome the tab can do itself; the rest belong to features this host
|
||||
// reports false for, and saying so beats a confusing "unknown command".
|
||||
if (cmd.startsWith("plugin:")) {
|
||||
return handleHostPluginCommand<T>(cmd, payload);
|
||||
}
|
||||
return connection.rpc<T>(cmd, payload);
|
||||
},
|
||||
|
||||
async rpcStream<T, M>(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
onMessage: (message: M) => void,
|
||||
): Promise<RpcStreamHandle<T>> {
|
||||
// Caller-minted id, subscribed before dispatch, exactly as on the
|
||||
// desktop: the command can emit its first message before it returns.
|
||||
const streamId = crypto.randomUUID();
|
||||
const unlisten = connection.listen(`stream_${streamId}`, (p) => onMessage(p as M));
|
||||
try {
|
||||
const result = await connection.rpc<T>(cmd, { ...payload, streamId });
|
||||
return { result, unlisten };
|
||||
} catch (err) {
|
||||
unlisten();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
listen: <T,>(event: string, callback: (payload: T) => void): Unsubscribe =>
|
||||
connection.listen(event, (payload) => callback(payload as T)),
|
||||
|
||||
emit: async (event, payload) => connection.emit(event, payload),
|
||||
|
||||
openUrl: async (url) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
|
||||
revealItemInDir: async () => {
|
||||
throw unsupported("Revealing a file");
|
||||
},
|
||||
|
||||
osType: detectOsType,
|
||||
appIdentifier: async () => "app.yaak.bridge",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Deciding which host to install, and getting a bridge token when there isn't
|
||||
* one yet.
|
||||
*
|
||||
* Dev-grade on purpose. The token is a shared secret the bridge prints at
|
||||
* startup, passed in the URL and kept for the session. OTP pairing and request
|
||||
* encryption replace this whole file; the seam is that nothing outside it knows
|
||||
* how the token was obtained.
|
||||
*/
|
||||
|
||||
export interface BridgeConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const TOKEN_STORAGE_KEY = "yaak.bridge.token";
|
||||
const TOKEN_QUERY_PARAM = "bridgeToken";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
}
|
||||
|
||||
// Declared here rather than by depending on Vite's types: this package is
|
||||
// consumed by a bundler that provides them, and only this one variable.
|
||||
interface ImportMeta {
|
||||
readonly env?: Record<string, string | undefined>;
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeUrl(): string {
|
||||
// Set when the frontend runs on a Vite dev server and the bridge is on its
|
||||
// own port. When the bridge serves the built app, they share an origin.
|
||||
const configured = import.meta.env?.VITE_YAAK_BRIDGE_URL;
|
||||
return (configured ?? window.location.origin).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge token, or null if the user hasn't supplied one.
|
||||
*
|
||||
* A token in the URL is consumed and stashed: leaving it in the address bar
|
||||
* means it lands in the router's own history entries and in anything the user
|
||||
* copies out of the bar.
|
||||
*/
|
||||
function readToken(): string | null {
|
||||
const url = new URL(window.location.href);
|
||||
const fromQuery = url.searchParams.get(TOKEN_QUERY_PARAM);
|
||||
|
||||
if (fromQuery != null && fromQuery !== "") {
|
||||
sessionStorage.setItem(TOKEN_STORAGE_KEY, fromQuery);
|
||||
url.searchParams.delete(TOKEN_QUERY_PARAM);
|
||||
history.replaceState(null, "", url.toString());
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** Whether this build should talk to a bridge at all. */
|
||||
export function shouldUseBridge(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
// Running inside the desktop app: Tauri always wins.
|
||||
if (window.__TAURI_INTERNALS__ != null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function bridgeConfig(): BridgeConfig | null {
|
||||
const token = readToken();
|
||||
if (token == null) return null;
|
||||
return { url: bridgeUrl(), token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the connect form on screen.
|
||||
*
|
||||
* Synchronous, and it does not stop anything by itself — the caller pairs it
|
||||
* with a host that never connects, so the app's own boot-time await is what
|
||||
* holds. Submitting reloads with the token in the query, which `readToken`
|
||||
* then consumes.
|
||||
*/
|
||||
export function promptForToken(): void {
|
||||
document.body.innerHTML = `
|
||||
<div style="font-family: system-ui, sans-serif; max-width: 26rem; margin: 15vh auto; padding: 0 1.5rem; color: #d5d3e0">
|
||||
<h1 style="font-size: 1.25rem; margin: 0 0 0.5rem">Connect to the Yaak Bridge</h1>
|
||||
<p style="margin: 0 0 1.25rem; line-height: 1.5; color: #9a97ad">
|
||||
Paste the token the bridge printed when it started.
|
||||
</p>
|
||||
<form id="yaak-bridge-connect" style="display: flex; gap: 0.5rem">
|
||||
<input name="token" autofocus autocomplete="off" spellcheck="false" placeholder="Bridge token"
|
||||
style="flex: 1; padding: 0.5rem 0.65rem; border-radius: 0.375rem; border: 1px solid #3b3950; background: #232135; color: inherit; font-family: ui-monospace, monospace" />
|
||||
<button type="submit"
|
||||
style="padding: 0.5rem 1rem; border-radius: 0.375rem; border: 0; background: #6d5ef0; color: white; font-weight: 500; cursor: pointer">
|
||||
Connect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
document.documentElement.style.background = "#1b1a29";
|
||||
|
||||
document.getElementById("yaak-bridge-connect")?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const token = new FormData(e.target as HTMLFormElement).get("token");
|
||||
if (typeof token !== "string" || token === "") return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(TOKEN_QUERY_PARAM, token);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,30 @@
|
||||
import { createBridgePlatform } from "./bridge";
|
||||
import { bridgeConfig, promptForToken, shouldUseBridge } from "./connect";
|
||||
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, and it has to run synchronously: several modules
|
||||
// call commands while the module graph is still evaluating, so there is no
|
||||
// later moment to install a host 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());
|
||||
// Both hosts are constructible without waiting for anything. The bridge host
|
||||
// opens its connection in the background and queues calls made before it lands,
|
||||
// which is why picking a host here does not mean blocking on one.
|
||||
if (shouldUseBridge()) {
|
||||
const config = bridgeConfig();
|
||||
if (config == null) {
|
||||
// No token yet: put the connect form on screen and install a host that
|
||||
// never connects. Boot then stalls at its own top-level await rather than
|
||||
// failing somewhere that doesn't explain itself — and it stalls in the one
|
||||
// place already designed to wait, which keeps this file synchronous.
|
||||
promptForToken();
|
||||
setPlatform(createBridgePlatform(window.location.origin, null));
|
||||
} else {
|
||||
setPlatform(createBridgePlatform(config.url, config.token));
|
||||
}
|
||||
} else {
|
||||
setPlatform(createTauriPlatform());
|
||||
}
|
||||
|
||||
export * from "./capabilities";
|
||||
export { platform, setPlatform } from "./registry";
|
||||
|
||||
Reference in New Issue
Block a user