mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-19 01:45:23 +02:00
Run the desktop's model layer in the browser (#557)
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
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.
|
||||
// The desktop entry. 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.
|
||||
// This line is the swap point, and a browser build swaps it by resolving the
|
||||
// package to `index.web.ts` instead of this file. Because nothing else in the
|
||||
// app imports a host directly, that is the whole change.
|
||||
setPlatform(createTauriPlatform());
|
||||
|
||||
export * from "./capabilities";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { setPlatform } from "./registry";
|
||||
import { createWebPlatform } from "./web";
|
||||
|
||||
/**
|
||||
* The package entry for browser builds, selected by aliasing
|
||||
* `@yaakapp-internal/platform` to this file (see `YAAK_TARGET=web` in the
|
||||
* client's vite.config.ts).
|
||||
*
|
||||
* A separate entry rather than a branch inside `index.ts`, because a branch
|
||||
* would still leave `import "@tauri-apps/api"` in the module graph: the folded
|
||||
* `if` disappears, but the imports it guarded do not, and those modules cannot
|
||||
* be proven side-effect free. Splitting the entry means a web build never
|
||||
* mentions Tauri at all — and it keeps `index.ts` exactly as the desktop has
|
||||
* always had it.
|
||||
*
|
||||
* Like `index.ts`, this must install the host eagerly and synchronously:
|
||||
* boot-time modules call commands while the module graph is still evaluating.
|
||||
*/
|
||||
setPlatform(createWebPlatform());
|
||||
|
||||
export * from "./capabilities";
|
||||
export { platform, setPlatform } from "./registry";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,193 @@
|
||||
# The browser host
|
||||
|
||||
Yaak running in a plain tab: no install, no local process. The desktop's own
|
||||
model layer — `yaak-models`, SQLite included — runs compiled to wasm inside a
|
||||
worker the tab talks to, so a browser stores exactly what a desktop install
|
||||
stores, migrations and all.
|
||||
|
||||
Select it at build time and run the frontend alone:
|
||||
|
||||
```shell
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
The flag resolves `@yaakapp-internal/platform` to `../index.web.ts`, which
|
||||
installs this host instead of the Tauri one. It is a separate entry rather than
|
||||
a branch inside `index.ts` so that a web build never pulls `@tauri-apps/*` into
|
||||
the module graph at all — a folded branch would drop the code but keep the
|
||||
imports it guarded.
|
||||
|
||||
Desktop builds are untouched: without the flag, `packages/platform/src/index.ts`
|
||||
installs the Tauri host exactly as before.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaakapp-internal/web (wasm)
|
||||
◀── model_writes ── crates/yaak-web → yaak-models → SQLite
|
||||
└─ pages in IndexedDB
|
||||
```
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `index.ts` | The `Platform` implementation. |
|
||||
| `commands.ts` | The command table: model commands forward to the worker; the rest is fixed answers and refusals-with-a-reason. |
|
||||
| `connection.ts` | A tab's end of the wire: request/response over a `MessagePort`, event delivery, and the tab's identity (`label`). |
|
||||
| `worker.ts` | The process that owns the database. Loads the wasm, opens the DB once, answers each port, fans `model_writes` out to every port. |
|
||||
| `protocol.ts` | The message shapes both sides import. |
|
||||
| `errors.ts` | `UnsupportedCommandError`, the structured refusal. |
|
||||
| `storage.ts` | `navigator.storage.persist()`. |
|
||||
|
||||
The Rust side is `crates/yaak-web` (`@yaakapp-internal/web`): `boot()`,
|
||||
`rpc(cmd, payload, label)` returning `{ result, events }`, and blob get/put.
|
||||
Its `pkg/` is committed; rebuilding needs a clang with a WebAssembly backend
|
||||
(`brew install llvm`), and `build-wasm.cjs` skips with a notice when there
|
||||
isn't one, so a desktop `npm run bootstrap` never depends on it.
|
||||
|
||||
Behaviours worth knowing before changing anything:
|
||||
|
||||
- **The worker is a `SharedWorker`, and only that.** The browser hands every
|
||||
tab on the origin the same one, which is what makes "one database owner"
|
||||
true without anyone coordinating — and makes the browser look like the
|
||||
desktop: one process holds the data, every window talks to it, it pushes
|
||||
writes to all of them. It still takes a Web Lock before opening, for the one
|
||||
overlap the browser doesn't rule out (a reloading tab's dying predecessor).
|
||||
There is deliberately no fallback to a per-tab worker: two kinds of worker
|
||||
that can both come up is a race. Every current browser, desktop and mobile,
|
||||
has `SharedWorker` and Web Locks (Chrome for Android since 148, April 2026);
|
||||
older ones get a clear "unsupported browser" message rather than a second
|
||||
SQLite over the same pages.
|
||||
- **Every write is stamped with the calling tab's `label`** as
|
||||
`UpdateSource::Window`, exactly like a desktop window label, so the frontend
|
||||
store's echo handling is unchanged.
|
||||
- **Cascade rules, duplicate naming, id generation, serde defaults, and the
|
||||
lazy first-run bootstrap are all the Rust code's.** Nothing about what a
|
||||
model *is* is decided in TypeScript.
|
||||
- **Persistence is `relaxed-idb`**: SQLite pages live in IndexedDB, writes land
|
||||
in memory and flush shortly after. A tab closing mid-flush loses at most the
|
||||
last few writes.
|
||||
|
||||
## Commands
|
||||
|
||||
109 commands are declared in `@yaakapp-internal/rpc-schema`. This host answers
|
||||
31, declines 44 by name with a reason, and refuses the remaining 34 generically.
|
||||
|
||||
### Implemented (31)
|
||||
|
||||
| Group | Commands |
|
||||
| --- | --- |
|
||||
| Models | `models_workspace_models`, `models_upsert`, `models_delete`, `models_duplicate`, `models_get_settings`, `models_get_graphql_introspection`, `models_upsert_graphql_introspection`, `models_grpc_events`, `models_websocket_events` |
|
||||
| App | `cmd_metadata`, `cmd_get_workspace_meta`, `cmd_default_headers`, `cmd_get_themes`, `cmd_check_for_updates`, `cmd_dismiss_notification`, `cmd_plugin_init_errors` |
|
||||
| Bodies | `cmd_http_response_body`, `cmd_http_response_body_path`, `cmd_http_request_body`, `cmd_get_http_response_events`, `cmd_get_sse_events` |
|
||||
| Plugin surfaces (empty results) | `cmd_http_request_actions`, `cmd_websocket_request_actions`, `cmd_grpc_request_actions`, `cmd_workspace_actions`, `cmd_folder_actions`, `cmd_template_function_summaries`, `cmd_get_http_authentication_summaries`, `cmd_get_http_authentication_config` |
|
||||
| Text | `cmd_format_json`, `cmd_render_template` |
|
||||
|
||||
Some of these answer honestly rather than fully, and the difference matters:
|
||||
|
||||
- `cmd_render_template` returns the template **unrendered**. Resolving variables
|
||||
and calling template functions is plugin work. The preview shows the raw
|
||||
`${[…]}` rather than a wrong value.
|
||||
- `cmd_get_http_authentication_summaries` returns the auth methods Yaak ships as
|
||||
plugins, so the picker is truthful about the product — but
|
||||
`cmd_get_http_authentication_config` returns an empty form, because the plugin
|
||||
that defines the form isn't running.
|
||||
- `cmd_template_function_summaries` returns one provider contributing no
|
||||
functions. Both summary commands are polled every second until they return
|
||||
something, so an empty list is a poll that never stops rather than a quiet no.
|
||||
- `cmd_metadata` reports empty strings for the data, log, plugin and project
|
||||
directories. There is no filesystem behind this host.
|
||||
|
||||
### Declined by name (44)
|
||||
|
||||
Each returns an `UnsupportedCommandError` carrying `cmd`, a user-facing
|
||||
`message`, and the `capability` a caller should have checked. The UI turns it
|
||||
into a toast.
|
||||
|
||||
| Reason | Commands |
|
||||
| --- | --- |
|
||||
| Sending isn't available yet (slice 2) | `cmd_send_http_request`, `cmd_send_ephemeral_request`, `cmd_delete_send_history`, `cmd_delete_all_http_responses`, `cmd_import_url` |
|
||||
| No plugin runtime | `cmd_reload_plugins`, `cmd_plugin_info`, `cmd_plugins_search`, `cmd_plugins_install`, `cmd_plugins_install_from_directory`, `cmd_plugins_uninstall`, `cmd_plugins_updates`, `cmd_plugins_update_all`, `cmd_template_function_config`, `cmd_template_tokens_to_string`, `cmd_call_http_request_action`, `cmd_call_websocket_request_action`, `cmd_call_grpc_request_action`, `cmd_call_workspace_action`, `cmd_call_folder_action`, `cmd_call_http_authentication_action`, `cmd_curl_to_request`, `cmd_format_graphql` |
|
||||
| No filesystem | `cmd_import_data`, `cmd_export_data`, `cmd_save_response`, `cmd_save_base64_to_binary` |
|
||||
| Needs a real socket | `cmd_grpc_reflect`, `cmd_grpc_go`, `cmd_delete_all_grpc_connections`, `cmd_ws_connect`, `cmd_ws_send`, `cmd_ws_close`, `cmd_ws_delete_connections` |
|
||||
| Workspace encryption | `cmd_enable_encryption`, `cmd_disable_encryption`, `cmd_reveal_workspace_key`, `cmd_set_workspace_key`, `cmd_secure_template`, `cmd_decrypt_template` |
|
||||
| One tab, no windows | `cmd_new_child_window`, `cmd_new_main_window`, `cmd_restart` |
|
||||
| Other | `cmd_send_feedback` |
|
||||
|
||||
### Refused generically (34)
|
||||
|
||||
The 30 `cmd_git_*` commands and `cmd_sync_calculate`, `cmd_sync_calculate_fs`,
|
||||
`cmd_sync_apply`, `cmd_sync_watch`. Nothing in the app reaches them unless a
|
||||
workspace has a sync directory, which a browser tab cannot set.
|
||||
|
||||
Anything added to the schema later also lands here, and the error names the
|
||||
command — an unlisted command is a gap in `commands.ts`, and whoever hits it
|
||||
should be able to see which.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Reported honestly, so callers gate on the question rather than on the host:
|
||||
|
||||
| True | False |
|
||||
| --- | --- |
|
||||
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
|
||||
|
||||
`multiWindow: false` means the host cannot open a *second window* on demand —
|
||||
what `cmd_new_child_window` does for Settings and workspace switching. It 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.
|
||||
|
||||
## Multiple tabs
|
||||
|
||||
Each tab mints a label at load (`tab_xxxxxxxx`) and sends it with every command;
|
||||
the worker stamps writes with it as `UpdateSource::Window { label }`, standing in
|
||||
for the desktop's window label. The worker fans each write out to every
|
||||
connected tab, and the receiving tab's store applies or ignores it exactly as a
|
||||
desktop window would.
|
||||
|
||||
The label is deliberately *not* kept in `sessionStorage`: duplicating a tab
|
||||
copies session storage, and two tabs sharing one identity would each mistake the
|
||||
other's writes for an echo of their own and drop them.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Storage persistence is requested, not guaranteed.** `navigator.storage.persist()`
|
||||
runs at boot; browsers grant it on their own heuristics and often decline on
|
||||
`localhost`.
|
||||
- **`pkg/yaak_web_bg.wasm` is 3.8 MB and committed** (no `wasm-opt`, matching
|
||||
`yaak-templates`). It will churn on every model-layer change; a CI-built
|
||||
artifact is the real answer.
|
||||
- **Settings opens in the same tab** and is left with the browser's Back button.
|
||||
- **Settings shows Data Directory / Logs Directory rows** with empty values; the
|
||||
Create Workspace dialog offers directory sync and encryption. Should be gated
|
||||
on `localFiles` / `sync` / `encryption`.
|
||||
- **`cmd_render_template` returns the template unrendered.** Resolving variables
|
||||
and calling template functions is plugin work.
|
||||
- **A declined command logs an unhandled rejection** next to its toast — the
|
||||
app's own `createFastMutation.mutate`, same on desktop.
|
||||
- **`yaak-rpc-schema` does not come to wasm** (it pulls the git/gRPC/plugin
|
||||
crates for their types), so the crate declares the handful of request shapes
|
||||
it needs locally, and `commands.ts` stays typed against `RpcSchema`.
|
||||
|
||||
## What slice 2 (the send proxy) will need from this layer
|
||||
|
||||
Sending becomes a stateless hosted service; this layer stays the only place data
|
||||
lives. Concretely:
|
||||
|
||||
1. **A rendered request to send.** The client assembles `HttpSendInputs` and
|
||||
posts it. Nothing about the workspace is uploaded except what this request
|
||||
needs.
|
||||
2. **Cookies out, cookies in.** The active `cookie_jar` model's `cookies` array
|
||||
goes up with the request; the proxy returns the jar as the exchange left it,
|
||||
and the client upserts it back through `models_upsert` like any other write.
|
||||
The proxy keeps nothing.
|
||||
3. **A response body sink.** `blob_put(responseId, bytes)` in the worker
|
||||
writes through the desktop's `blob_manager`, chunked the way it chunks.
|
||||
Streaming will want an append path rather than one whole-body write.
|
||||
4. **A request body sink** under `${responseId}.request`, which
|
||||
`cmd_http_request_body` already reads.
|
||||
5. **Response and timeline models.** `cmd_send_http_request` currently declines;
|
||||
it will instead upsert an `http_response` as the exchange progresses, plus
|
||||
`http_response_event` rows once `timeline` becomes true. Both flow through
|
||||
the same `write()` helper, so other tabs see a send land live.
|
||||
6. **Blob cleanup is the desktop's.** `delete_http_response` and
|
||||
`delete_workspace` in `yaak-models` already remove blob chunks.
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* The command table: what this host answers, and what it declines and why.
|
||||
*
|
||||
* The model commands are forwarded to the worker, where the desktop's own
|
||||
* model layer answers them — same queries, same migrations, same cascade
|
||||
* rules — so nothing about *what a model is* is decided in this file. What is
|
||||
* decided here is the rest of the desktop's command surface: a handful of
|
||||
* fixed answers that are true of a browser tab, and the refusals. The refusals
|
||||
* are the important half: a command that silently returns nothing leaves the
|
||||
* UI showing something that isn't true, whereas a refusal with a reason becomes
|
||||
* a toast the user can act on. So each unsupported command is listed by name
|
||||
* with the reason, and anything not listed at all is refused generically
|
||||
* rather than guessed at.
|
||||
*
|
||||
* The command names are `keyof RpcSchema`, the same generated wire schema the
|
||||
* desktop's router is built from, so a command renamed or added in Rust shows
|
||||
* up here as a type error rather than as a runtime surprise.
|
||||
*/
|
||||
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
import type { CapabilityName, RpcPayload } from "../types";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
|
||||
export type AppCmd = keyof RpcSchema;
|
||||
|
||||
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
|
||||
|
||||
/** Placeholder shown wherever the desktop would show a real filesystem path. */
|
||||
const NO_PATH = "";
|
||||
|
||||
function str(payload: RpcPayload, key: string): string | null {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" && value !== "" ? value : null;
|
||||
}
|
||||
|
||||
/** Like `str`, but for fields where an empty string is a legitimate value. */
|
||||
function text(payload: RpcPayload, key: string): string {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands this host answers itself.
|
||||
*
|
||||
* Anything here either reads and writes the browser's own database, or is a
|
||||
* fixed answer that is true of this host — not a stub standing in for something
|
||||
* that should work.
|
||||
*/
|
||||
const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
/* ------------------------------- models -------------------------------- */
|
||||
|
||||
// Answered by the model layer itself, in the worker. The payload goes over
|
||||
// untouched and the answer comes back untouched: this file has no opinion
|
||||
// about models, and it would be wrong for it to grow one.
|
||||
models_workspace_models: (payload, db) => db.rpc("models_workspace_models", payload),
|
||||
models_upsert: (payload, db) => db.rpc("models_upsert", payload),
|
||||
models_delete: (payload, db) => db.rpc("models_delete", payload),
|
||||
models_duplicate: (payload, db) => db.rpc("models_duplicate", payload),
|
||||
models_get_settings: (payload, db) => db.rpc("models_get_settings", payload),
|
||||
models_get_graphql_introspection: (payload, db) =>
|
||||
db.rpc("models_get_graphql_introspection", payload),
|
||||
models_upsert_graphql_introspection: (payload, db) =>
|
||||
db.rpc("models_upsert_graphql_introspection", payload),
|
||||
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
|
||||
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
|
||||
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
|
||||
async cmd_metadata() {
|
||||
return {
|
||||
isDev: true,
|
||||
version: "0.0.0-web",
|
||||
cliVersion: null,
|
||||
name: "Yaak",
|
||||
// The desktop hands out real directories here and the UI offers to open
|
||||
// them. There is no filesystem behind this host, and the capability flags
|
||||
// are what the UI should be gating those affordances on.
|
||||
appDataDir: NO_PATH,
|
||||
appLogDir: NO_PATH,
|
||||
vendoredPluginDir: NO_PATH,
|
||||
defaultProjectDir: NO_PATH,
|
||||
featureUpdater: false,
|
||||
featureLicense: false,
|
||||
};
|
||||
},
|
||||
|
||||
// The theme package ships its own defaults, so an empty list is a complete
|
||||
// answer rather than a degraded one — themes beyond those come from plugins.
|
||||
async cmd_get_themes() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async cmd_default_headers() {
|
||||
// Mirrors `default_headers()` in crates/yaak-models/src/queries/workspaces.rs
|
||||
return [
|
||||
{ enabled: true, name: "User-Agent", value: "yaak", id: null },
|
||||
{ enabled: true, name: "Accept", value: "*/*", id: null },
|
||||
];
|
||||
},
|
||||
|
||||
async cmd_plugin_init_errors() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async cmd_check_for_updates() {
|
||||
return false;
|
||||
},
|
||||
|
||||
async cmd_dismiss_notification() {
|
||||
return null;
|
||||
},
|
||||
|
||||
// Plugin-contributed menus. Empty is honest: no plugin runtime, no actions.
|
||||
async cmd_http_request_actions() {
|
||||
return [];
|
||||
},
|
||||
async cmd_websocket_request_actions() {
|
||||
return [];
|
||||
},
|
||||
async cmd_grpc_request_actions() {
|
||||
return [];
|
||||
},
|
||||
async cmd_workspace_actions() {
|
||||
return [];
|
||||
},
|
||||
async cmd_folder_actions() {
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Both of these are polled once a second until they answer with something, so
|
||||
* an empty list is not a quiet no — it is a poll that never stops.
|
||||
*
|
||||
* The auth list names what Yaak actually offers, so the picker tells the
|
||||
* truth about the product even though the form behind each entry stays empty
|
||||
* until plugins run here. Template functions get the opposite treatment: one
|
||||
* provider contributing no functions. That settles the poll while putting
|
||||
* nothing in the autocomplete, which is the honest answer — a function the
|
||||
* user could insert but nothing could evaluate would be worse than none.
|
||||
*/
|
||||
async cmd_get_http_authentication_summaries() {
|
||||
return HTTP_AUTHENTICATION_SUMMARIES;
|
||||
},
|
||||
async cmd_template_function_summaries() {
|
||||
return [{ pluginRefId: "web", functions: [] }];
|
||||
},
|
||||
|
||||
async cmd_get_http_authentication_config() {
|
||||
return { args: [], pluginRefId: "web" };
|
||||
},
|
||||
|
||||
async cmd_format_json(payload) {
|
||||
const source = text(payload, "text");
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(source), null, 2);
|
||||
} catch {
|
||||
// Formatting invalid JSON is a no-op, not an error: the editor calls this
|
||||
// while the user is still typing.
|
||||
return source;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Rendering resolves variables and calls template functions, and the
|
||||
* functions live in plugins. Handing the template back unrendered is what the
|
||||
* preview then shows — the raw `${[...]}`, which is at least the thing the
|
||||
* user typed rather than a wrong value.
|
||||
*/
|
||||
async cmd_render_template(payload) {
|
||||
return text(payload, "template");
|
||||
},
|
||||
|
||||
/* ------------------------------- bodies -------------------------------- */
|
||||
|
||||
async cmd_http_response_body(payload, db) {
|
||||
const responseId = str(payload, "responseId");
|
||||
if (responseId == null) return { content: "" };
|
||||
|
||||
if (str(payload, "filter") != null) {
|
||||
return {
|
||||
content: "",
|
||||
error: "Response filters come from a plugin, which this host doesn't run yet",
|
||||
};
|
||||
}
|
||||
|
||||
const bytes = await db.blobGet(responseId);
|
||||
return { content: bytes == null ? "" : new TextDecoder().decode(bytes) };
|
||||
},
|
||||
|
||||
// Bodies live in this database, not on a disk, so there is no path to give.
|
||||
async cmd_http_response_body_path() {
|
||||
return null;
|
||||
},
|
||||
|
||||
async cmd_http_request_body(payload, db) {
|
||||
const responseId = str(payload, "responseId");
|
||||
if (responseId == null) return null;
|
||||
// Keyed the way the desktop keys it: the request bytes belong to the
|
||||
// response that recorded them.
|
||||
const bytes = await db.blobGet(`${responseId}.request`);
|
||||
return bytes == null ? null : Array.from(bytes);
|
||||
},
|
||||
|
||||
async cmd_get_http_response_events() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async cmd_get_sse_events() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The auth methods Yaak ships as plugins today (plugins/auth-*). Listed so the
|
||||
* picker is truthful about the product; choosing one currently yields an empty
|
||||
* config form, because the plugin that defines the form isn't running.
|
||||
*/
|
||||
const HTTP_AUTHENTICATION_SUMMARIES = [
|
||||
{ name: "apikey", label: "API Key", shortLabel: "API Key" },
|
||||
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
|
||||
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
|
||||
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
|
||||
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
|
||||
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
|
||||
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
|
||||
{ name: "oauth2", label: "OAuth 2.0", shortLabel: "OAuth 2" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Commands this host declines, each with the reason a user would need.
|
||||
*
|
||||
* Naming them individually rather than letting them fall through to a generic
|
||||
* refusal is deliberate: "sending is not available yet" and "Yaak in a browser
|
||||
* has no filesystem" are different situations, and the second is permanent
|
||||
* while the first is a slice away.
|
||||
*/
|
||||
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | null]>> = {
|
||||
// Sending — the next slice. Everything else about a request works today;
|
||||
// only the part that puts bytes on the network is missing.
|
||||
cmd_send_http_request: [
|
||||
"Sending isn't available in the browser yet — everything else about this request is saved",
|
||||
null,
|
||||
],
|
||||
cmd_send_ephemeral_request: [
|
||||
"Sending isn't available in the browser yet — everything else about this request is saved",
|
||||
null,
|
||||
],
|
||||
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
|
||||
|
||||
// Protocols that need a real socket.
|
||||
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
|
||||
cmd_grpc_go: ["gRPC isn't available in the browser", "grpc"],
|
||||
cmd_delete_all_grpc_connections: ["gRPC isn't available in the browser", "grpc"],
|
||||
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
|
||||
// Anything that needs files the page can't reach.
|
||||
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
|
||||
cmd_import_url: ["Importing from a URL needs the send proxy, which isn't available yet", null],
|
||||
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
|
||||
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
|
||||
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
||||
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
|
||||
|
||||
// Windows. A tab is the window, and there is only ever one of it.
|
||||
cmd_new_child_window: ["Yaak in a browser uses one tab", "multiWindow"],
|
||||
cmd_new_main_window: ["Yaak in a browser uses one tab", "multiWindow"],
|
||||
cmd_restart: ["Reload the page to restart Yaak", null],
|
||||
|
||||
// Workspace encryption is backed by a key the host keeps for you; a page has
|
||||
// nowhere to keep one that a page couldn't also read.
|
||||
cmd_enable_encryption: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_disable_encryption: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_reveal_workspace_key: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_set_workspace_key: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_secure_template: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_decrypt_template: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
|
||||
// The plugin runtime is a Node process. Nothing here runs one.
|
||||
cmd_reload_plugins: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugin_info: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_search: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_install: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_install_from_directory: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
// Sending history and its bookkeeping belong to the send slice.
|
||||
cmd_delete_send_history: ["Sending isn't available in the browser yet", null],
|
||||
cmd_delete_all_http_responses: ["Sending isn't available in the browser yet", null],
|
||||
|
||||
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
|
||||
};
|
||||
|
||||
/**
|
||||
* The support table, for documentation and for the console.
|
||||
*
|
||||
* Derived from the two maps above rather than written alongside them, so it
|
||||
* cannot drift from what the host actually does.
|
||||
*/
|
||||
export function commandSupport(): {
|
||||
implemented: string[];
|
||||
declined: { cmd: string; reason: string; capability: CapabilityName | null }[];
|
||||
} {
|
||||
return {
|
||||
implemented: Object.keys(HANDLERS).sort(),
|
||||
declined: Object.entries(DECLINED)
|
||||
.map(([cmd, [reason, capability]]) => ({ cmd, reason, capability }))
|
||||
.sort((a, b) => a.cmd.localeCompare(b.cmd)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCommand(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
db: WorkerConnection,
|
||||
): Promise<unknown> {
|
||||
const handler = HANDLERS[cmd as AppCmd];
|
||||
if (handler != null) return handler(payload, db);
|
||||
|
||||
const declined = DECLINED[cmd as AppCmd];
|
||||
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
|
||||
|
||||
// Git and sync land here, along with anything added to the schema since. The
|
||||
// message names the command because an unlisted one is a gap in this file,
|
||||
// and whoever hits it should be able to see which.
|
||||
throw unsupported(cmd, `\`${cmd}\` isn't available when Yaak runs in a browser`);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* A tab's end of the wire to the worker that owns the database.
|
||||
*
|
||||
* Constructible synchronously and usable immediately, which is the hard
|
||||
* requirement: boot-time modules call commands while the module graph is still
|
||||
* evaluating, so there is no later moment to connect in. Messages posted before
|
||||
* the worker has opened the database sit in the port until it has, and the
|
||||
* app's own top-level await then doubles as the boot gate — nothing renders
|
||||
* until the first command has answered, and it can only answer once the
|
||||
* database is open.
|
||||
*/
|
||||
|
||||
import type { Unsubscribe } from "../types";
|
||||
import { type FromWorker, type ToWorker, WORKER_NAME } from "./protocol";
|
||||
|
||||
/**
|
||||
* How long a freshly connected worker gets to say hello.
|
||||
*
|
||||
* A live worker answers in the same turn it is connected — the worker script
|
||||
* is tiny and imports the model layer lazily, so this measures liveness, not
|
||||
* load time. It only has to be longer than a cold fetch of that small script;
|
||||
* a worker silent past this is not coming, and a message beats a blank page.
|
||||
*/
|
||||
const HELLO_TIMEOUT_MS = 3000;
|
||||
|
||||
const WORKER_FAILED = "Yaak's database worker could not be started. Reload the page to try again";
|
||||
|
||||
const UNSUPPORTED =
|
||||
"This browser can't run Yaak: it needs shared workers and Web Locks to keep your data safe across tabs. Every current browser has both.";
|
||||
|
||||
type Pending = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
|
||||
|
||||
export class WorkerConnection {
|
||||
private port: MessagePort | null;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
private readonly listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
private nextId = 1;
|
||||
private bootError: string | null = null;
|
||||
|
||||
/**
|
||||
* This tab's identity, standing in for the desktop's window label. Stamped
|
||||
* on every write this tab makes, so the store can tell an echo of its own
|
||||
* write from another tab's. Minted per page load, not kept in
|
||||
* `sessionStorage`, on purpose: duplicating a tab copies session storage,
|
||||
* and two tabs claiming one identity would each drop the other's writes as
|
||||
* echoes.
|
||||
*/
|
||||
readonly label = `tab_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
/** True once the worker has said anything at all. */
|
||||
private heard = false;
|
||||
|
||||
constructor() {
|
||||
// Both are required and neither is faked. Without a shared worker every
|
||||
// tab would need its own SQLite over the same pages; without Web Locks
|
||||
// nothing can promise there is only one even so. Every current browser,
|
||||
// desktop and mobile, has both (Chrome for Android since 148); the ones
|
||||
// that don't get told, not corrupted.
|
||||
if (typeof SharedWorker === "undefined" || typeof navigator.locks === "undefined") {
|
||||
this.port = null;
|
||||
this.bootError = UNSUPPORTED;
|
||||
showBootError(UNSUPPORTED);
|
||||
return;
|
||||
}
|
||||
|
||||
this.port = this.connect();
|
||||
|
||||
// Let the worker forget this port. Not load-bearing — a port that never
|
||||
// says goodbye is a leaked entry in a Set — but tidy.
|
||||
window.addEventListener("pagehide", () => this.post({ type: "goodbye" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the origin's one database worker.
|
||||
*
|
||||
* The browser hands every tab the same SharedWorker for this name and URL,
|
||||
* which is what makes "one database owner" true without anyone coordinating.
|
||||
*
|
||||
* `new URL("./worker.ts", import.meta.url)` is written out inline on purpose:
|
||||
* that exact syntax is what the bundler pattern-matches to know it must
|
||||
* bundle a worker entry. Hoisted into a variable it becomes an asset URL and
|
||||
* ships as raw TypeScript.
|
||||
*/
|
||||
private connect(): MessagePort {
|
||||
const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: WORKER_NAME,
|
||||
});
|
||||
// A worker whose script fails to load fires `error` on the SharedWorker
|
||||
// object and nothing else — the port just goes quiet.
|
||||
worker.onerror = () => {
|
||||
if (!this.heard) this.failEverything(`${WORKER_FAILED} (its script failed to load).`);
|
||||
};
|
||||
worker.port.onmessage = (e: MessageEvent<FromWorker>) => this.receive(e.data);
|
||||
worker.port.start();
|
||||
|
||||
// The worker says hello synchronously on connect. Silence past the timeout
|
||||
// means this port is attached to nothing that will ever answer, and the
|
||||
// user should see that rather than a blank page. It is not retried: the
|
||||
// one way this used to happen (a module worker missing connects during a
|
||||
// top-level-await import) is fixed at the source by importing the wasm
|
||||
// lazily, and a reload is the right remedy for anything else.
|
||||
setTimeout(() => {
|
||||
if (!this.heard) this.failEverything(`${WORKER_FAILED} (it never answered).`);
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
|
||||
return worker.port;
|
||||
}
|
||||
|
||||
private failEverything(message: string): void {
|
||||
this.bootError = message;
|
||||
showBootError(message);
|
||||
for (const [id, p] of this.pending) {
|
||||
this.pending.delete(id);
|
||||
p.reject(new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
private post(message: ToWorker, transfer: Transferable[] = []): void {
|
||||
this.port?.postMessage(message, transfer);
|
||||
}
|
||||
|
||||
private receive(message: FromWorker): void {
|
||||
this.heard = true;
|
||||
switch (message.type) {
|
||||
case "hello":
|
||||
case "ready":
|
||||
return;
|
||||
case "boot_error":
|
||||
// Nothing will ever answer, and the app cannot render without an
|
||||
// answer, so say what happened where the user can see it. This is the
|
||||
// page's whole content at this point.
|
||||
this.failEverything(message.message);
|
||||
return;
|
||||
case "result": {
|
||||
const p = this.pending.get(message.id);
|
||||
this.pending.delete(message.id);
|
||||
p?.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
case "error": {
|
||||
const p = this.pending.get(message.id);
|
||||
this.pending.delete(message.id);
|
||||
p?.reject(new Error(message.message));
|
||||
return;
|
||||
}
|
||||
case "event":
|
||||
this.deliver(message.event, message.payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private request<T>(build: (id: number) => ToWorker, transfer: Transferable[] = []): Promise<T> {
|
||||
if (this.bootError != null) return Promise.reject(new Error(this.bootError));
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
||||
this.post(build(id), transfer);
|
||||
});
|
||||
}
|
||||
|
||||
rpc<T>(cmd: string, payload: unknown): Promise<T> {
|
||||
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
|
||||
}
|
||||
|
||||
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
|
||||
return buf == null ? null : new Uint8Array(buf);
|
||||
}
|
||||
|
||||
blobPut(blobId: string, bytes: Uint8Array): Promise<void> {
|
||||
// Copied so the caller's buffer isn't detached out from under it, then
|
||||
// transferred so the copy isn't copied again crossing to the worker.
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return this.request<void>(
|
||||
(id) => ({ type: "blob_put", id, blobId, bytes: copy.buffer }),
|
||||
[copy.buffer],
|
||||
);
|
||||
}
|
||||
|
||||
blobDelete(blobId: string): Promise<void> {
|
||||
return this.request<void>((id) => ({ type: "blob_delete", id, blobId }));
|
||||
}
|
||||
|
||||
/* ------------------------------- events -------------------------------- */
|
||||
|
||||
listen(event: string, callback: (payload: unknown) => void): Unsubscribe {
|
||||
let set = this.listeners.get(event);
|
||||
if (set == null) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
return () => {
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an event to this tab's listeners.
|
||||
*
|
||||
* Used for what the worker pushes, and for the app's own local emits (a
|
||||
* plugin round trip, a stream teardown). Local emits stay local: every
|
||||
* emitter in the app is replying to something *this* tab is doing.
|
||||
*/
|
||||
deliver(event: string, payload: unknown): void {
|
||||
const set = this.listeners.get(event);
|
||||
if (set == null) return;
|
||||
// Copied because a listener may unsubscribe itself while being called
|
||||
for (const callback of Array.from(set)) {
|
||||
try {
|
||||
callback(payload);
|
||||
} catch (err) {
|
||||
console.error(`Listener for \`${event}\` threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showBootError(message: string): void {
|
||||
const root = document.getElementById("root");
|
||||
if (root == null || root.childElementCount > 0) return;
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText =
|
||||
"font: 15px/1.5 system-ui, sans-serif; max-width: 32rem; margin: 20vh auto; padding: 0 1rem; color: inherit";
|
||||
el.textContent = message;
|
||||
root.appendChild(el);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CapabilityName } from "../types";
|
||||
|
||||
/**
|
||||
* What this host says when it is asked for something it doesn't have.
|
||||
*
|
||||
* A rejected command reaches the user as a toast built from `message`, so the
|
||||
* message is the user-facing text and has to read like one. The structured
|
||||
* fields alongside it are for code: `capability` names the switch a caller
|
||||
* should have checked first, and `cmd` identifies the command without anyone
|
||||
* having to parse prose back out of the message.
|
||||
*/
|
||||
export class UnsupportedCommandError extends Error {
|
||||
readonly name = "UnsupportedCommandError";
|
||||
/** Stable discriminator, so a caller can branch without matching on text. */
|
||||
readonly code = "unsupported_command";
|
||||
readonly cmd: string;
|
||||
readonly capability: CapabilityName | null;
|
||||
|
||||
constructor(cmd: string, message: string, capability: CapabilityName | null = null) {
|
||||
super(message);
|
||||
this.cmd = cmd;
|
||||
this.capability = capability;
|
||||
}
|
||||
}
|
||||
|
||||
export function unsupported(
|
||||
cmd: string,
|
||||
reason: string,
|
||||
capability: CapabilityName | null = null,
|
||||
): UnsupportedCommandError {
|
||||
return new UnsupportedCommandError(cmd, reason, capability);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The messages that cross between a tab and the worker that owns the database.
|
||||
*
|
||||
* Declared once and imported from both sides, so a change to the shape is a
|
||||
* type error in whichever side forgot. Kept deliberately small: commands in,
|
||||
* results or errors out, and events pushed the other way — the same envelope
|
||||
* the desktop's IPC uses, because that is what the frontend is written against.
|
||||
*/
|
||||
|
||||
/** Tab → worker */
|
||||
export type ToWorker =
|
||||
| { type: "rpc"; id: number; cmd: string; payload: unknown; label: string }
|
||||
| { type: "blob_get"; id: number; blobId: string }
|
||||
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
|
||||
| { type: "blob_delete"; id: number; blobId: string }
|
||||
/** The tab is going away; the worker can forget its port. */
|
||||
| { type: "goodbye" };
|
||||
|
||||
/** Worker → tab */
|
||||
export type FromWorker =
|
||||
/**
|
||||
* Sent synchronously the moment a port connects, before anything else. Its
|
||||
* only job is to prove the worker is alive: a tab that connects during a
|
||||
* shared worker's teardown gets a port that is accepted and then never
|
||||
* serviced, and this is how it tells that apart from a slow boot.
|
||||
*/
|
||||
| { type: "hello" }
|
||||
/** The database is open. Sent to each port once boot has finished. */
|
||||
| { type: "ready" }
|
||||
/** The database could not be opened; every command will fail with this. */
|
||||
| { type: "boot_error"; message: string }
|
||||
| { type: "result"; id: number; result: unknown }
|
||||
| { type: "error"; id: number; message: string }
|
||||
/** A backend event for the app — today only `model_writes`. Sent to every port. */
|
||||
| { type: "event"; event: string; payload: unknown };
|
||||
|
||||
/** What the worker registers itself under. Tabs on one origin share it. */
|
||||
export const WORKER_NAME = "yaak-db";
|
||||
|
||||
/**
|
||||
* The Web Lock the worker takes before opening the database.
|
||||
*
|
||||
* The browser already guarantees one SharedWorker per origin for this name.
|
||||
* The lock covers the one overlap it doesn't rule out: a tab reloading itself,
|
||||
* whose old worker may still be letting go while the new one comes up.
|
||||
*/
|
||||
export const DB_LOCK_NAME = "yaak-db";
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Ask the browser not to evict this origin's data under storage pressure.
|
||||
*
|
||||
* Without it IndexedDB — where the worker's SQLite pages live — is "best
|
||||
* effort", and a browser clearing space can drop a user's workspaces. Granting
|
||||
* is the browser's call; it typically says yes once a site looks installed or
|
||||
* engaged, and often says no on localhost. This is a request, not a guarantee,
|
||||
* and there is nothing useful to do when it declines.
|
||||
*/
|
||||
export async function requestPersistence(): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.storage?.persist == null) return false;
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/**
|
||||
* The process that owns the database.
|
||||
*
|
||||
* On the desktop that is the Rust binary: it holds SQLite, every window talks
|
||||
* to it, and it pushes model writes to all of them. In a browser this worker
|
||||
* plays that part. It loads the model layer compiled to wasm, opens the one
|
||||
* database, answers each tab's commands over its port, and fans every
|
||||
* `model_writes` out to every port — so two tabs are coherent for the same
|
||||
* reason two desktop windows are, not because of a side channel.
|
||||
*
|
||||
* It is a SharedWorker, and only that: the browser hands every tab on the
|
||||
* origin the same one, which is what makes "one database owner" true without
|
||||
* anyone coordinating. It still takes a Web Lock before opening the database,
|
||||
* for the one overlap the browser doesn't rule out — a tab reloading itself,
|
||||
* whose old worker may still be letting go while the new one comes up.
|
||||
*/
|
||||
|
||||
import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
|
||||
|
||||
/**
|
||||
* The wasm is imported lazily, inside `boot()`, rather than at the top of the
|
||||
* module. That keeps this script's own evaluation instant, so a tab's connect
|
||||
* gets its `hello` immediately regardless of how long the model layer takes to
|
||||
* download and compile — and the tab can therefore tell "this worker is dead"
|
||||
* from "this worker is busy" with a short timeout.
|
||||
*/
|
||||
type Engine = typeof import("@yaakapp-internal/web");
|
||||
let engine: Engine | null = null;
|
||||
|
||||
const ports = new Set<MessagePort>();
|
||||
|
||||
/** Resolves once `boot()` has, or rejects with why it couldn't. */
|
||||
let booted: Promise<void> | null = null;
|
||||
let bootError: string | null = null;
|
||||
|
||||
function send(port: MessagePort, message: FromWorker, transfer: Transferable[] = []): void {
|
||||
port.postMessage(message, transfer);
|
||||
}
|
||||
|
||||
function broadcast(message: FromWorker): void {
|
||||
for (const port of ports) send(port, message);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait for the database lock before concluding someone else has it
|
||||
* for good. The wait exists for one case: a tab reloading itself. Its old
|
||||
* worker still holds the lock while it is torn down, and the new worker only
|
||||
* needs it to let go — which takes milliseconds, not seconds. Anything longer
|
||||
* is a live worker in another tab, and the honest answer is to say so.
|
||||
*/
|
||||
const LOCK_TIMEOUT_MS = 3000;
|
||||
|
||||
const ALREADY_OPEN =
|
||||
"Yaak's database is held by another worker that isn't letting go. Close Yaak's other tabs and reload.";
|
||||
|
||||
/**
|
||||
* Take the lock, or explain why not.
|
||||
*
|
||||
* Held for the life of the worker: the callback's promise never settles, so
|
||||
* the browser keeps the lock until this worker is gone. Requested with a
|
||||
* timeout rather than `ifAvailable`, so a dying predecessor's brief hold is
|
||||
* waited out but a live one is reported.
|
||||
*/
|
||||
function acquireDatabaseLock(): Promise<void> {
|
||||
// The tab checked for Web Locks before it ever connected; a worker without
|
||||
// them would be a browser lying about its own features.
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
navigator.locks
|
||||
.request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => {
|
||||
resolve();
|
||||
return new Promise<void>(() => {});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const name = (err as { name?: string } | null)?.name;
|
||||
reject(name === "AbortError" || name === "TimeoutError" ? new Error(ALREADY_OPEN) : err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the database, once, for everyone.
|
||||
*
|
||||
* Lock first, then load the model layer, then open — in that order, so a
|
||||
* worker that will never own the database also never downloads and compiles
|
||||
* the wasm for it.
|
||||
*/
|
||||
function bootOnce(): Promise<void> {
|
||||
if (booted != null) return booted;
|
||||
|
||||
booted = (async () => {
|
||||
await acquireDatabaseLock();
|
||||
const loaded = await import("@yaakapp-internal/web");
|
||||
await loaded.boot();
|
||||
engine = loaded;
|
||||
})();
|
||||
|
||||
booted.catch((err) => {
|
||||
bootError = errorMessage(err);
|
||||
});
|
||||
|
||||
return booted;
|
||||
}
|
||||
|
||||
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
if (message.type === "goodbye") {
|
||||
ports.delete(port);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every command waits for boot rather than the tab having to. Tabs post
|
||||
// the moment they load; the port queues; this drains once the DB is open.
|
||||
try {
|
||||
await booted;
|
||||
} catch {
|
||||
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
|
||||
return;
|
||||
}
|
||||
const { rpc, blob_get, blob_put, blob_delete } = engine!;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "rpc": {
|
||||
const outcome = rpc(message.cmd, message.payload, message.label) as {
|
||||
result: unknown;
|
||||
events: unknown[];
|
||||
};
|
||||
// Result first, to the caller; then the writes, to everyone including
|
||||
// the caller. The store applies its own echo the same as any other
|
||||
// window's, so it must arrive — and the caller's `await` resolving
|
||||
// before its echo lands is fine, because it resolves on the same tick
|
||||
// and the store reads on the next.
|
||||
send(port, { type: "result", id: message.id, result: outcome.result });
|
||||
if (outcome.events.length > 0) {
|
||||
broadcast({ type: "event", event: "model_writes", payload: outcome.events });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "blob_get": {
|
||||
const bytes = blob_get(message.blobId);
|
||||
if (bytes == null) {
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
} else {
|
||||
// Copy into a fresh buffer we can transfer: the wasm's memory
|
||||
// cannot leave the worker.
|
||||
const out = new Uint8Array(bytes.byteLength);
|
||||
out.set(bytes);
|
||||
send(port, { type: "result", id: message.id, result: out.buffer }, [out.buffer]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "blob_put": {
|
||||
blob_put(message.blobId, new Uint8Array(message.bytes));
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
case "blob_delete": {
|
||||
blob_delete(message.blobId);
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
send(port, { type: "error", id: message.id, message: errorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function attach(port: MessagePort): void {
|
||||
ports.add(port);
|
||||
port.onmessage = (e: MessageEvent<ToWorker>) => void handle(port, e.data);
|
||||
port.start?.();
|
||||
|
||||
// Proof of life, before boot: the tab is timing this.
|
||||
send(port, { type: "hello" });
|
||||
|
||||
bootOnce().then(
|
||||
() => send(port, { type: "ready" }),
|
||||
(err) => send(port, { type: "boot_error", message: errorMessage(err) }),
|
||||
);
|
||||
}
|
||||
|
||||
// Each tab arrives as a connect event with its own port.
|
||||
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
|
||||
attach(e.ports[0]!);
|
||||
};
|
||||
Reference in New Issue
Block a user