# The wayBetter Store, install links and the install screen Every wayBetter app can install themes and plugins from a dropped file, a `waybetter://install` link or the signed wayBetter Store, always through its install screen. The framework has all of it: the rules and the state (waybetter-host `store`), the UI (`waybetter/shell/InstallDialog`, `waybetter/shell/StoreView`), the strings in every language (`wb.store.*`, `wb.install.*`, `wb.permission.*` in the framework catalogs) and the tooling (`waybetter store build|sign|verify`). An app adds its settings, its Tauri command wrappers, its theme format and its look. ## One catalog, many apps The store is one folder, `https://waybetter.fyi/store/`, with one signed catalog shared by every app (`catalog.json` + `catalog.json.sig`). Each item names the apps it is for; an app verifies the whole file, then keeps only its own items: it never shows, stages or installs another app's item, even one listed in the same signed file. ```json { "format": 2, "name": "wayBetter Store", "generatedAt": "2026-09-29T04:18:08Z", "items": [ { "apps": ["x"], "kind": "plugin", "id": "thread-reader", "name": "Thread Reader", "author": "wayBetter", "version": "1.1.2", "description": "…", "category": "reading", "tags": ["threads"], "url": "https://waybetter.fyi/store/plugins/bt-thread-reader.plugin.js", "file": "bt-thread-reader.plugin.js", "sha256": "<64 lowercase hex>", "size": 12345, "preview": "https://waybetter.fyi/store/previews/plugin-thread-reader.svg", "permissions": ["ui", "clipboard", "commands", "network:none"] }, { "apps": ["x"], "kind": "theme", "id": "nord", "name": "Nord", "author": "", "version": "1.0.0", "description": "…", "category": "dark", "tags": ["dark"], "url": "https://waybetter.fyi/store/themes/nord.json", "sha256": "…", "size": 1700, "preview": "…", "colors": { "bg": "#232831", "surface": "#2e3440", "text": "#eceff4", "accent": "#88c0d0" } } ] } ``` JSON Schema: `waybetter/store-catalog.schema.json` (npm package). The rules apps enforce (waybetter-host `store::catalog`, mirrored by the CLI): | Field | Rule | | --- | --- | | `format` | `2`. An app that reads another format says "update the app". Format 1 (before `apps`) is gone. | | `apps` | 1–16 distinct app ids (`[a-z0-9-]{1,32}`), e.g. `"x"`; an item for several apps lists each. | | `kind`, `id` | `theme`, `plugin`, `strings` (a string pack, docs/STRING-PACKS.md), `icons` (an icon pack, `*.icons.json`: data, at most 120 KB, its own `id` is what it is installed under) or `builtin` (a feature that ships inside the app, below); `[a-z0-9-]{1,40}`, unique per app and kind. | | `name`, `author`, `version`, `description`, `category`, `tags` | plain text (no control characters), ≤ 60 / 60 / 32 / 600 / 32 characters, ≤ 12 tags of ≤ 32. Known categories show in the reader's language (`wb.store.category.*`). | | `url`, `preview` | https; for the reading app's own items, on its download hosts (below). | | `file` | plugins only: `name.plugin.js` (letters, digits, `-_. `), unique per app ignoring case. | | `url`, `sha256`, `size` | of the exact file; size 1 byte … 2 MiB (icon packs 120 KB). Absent on a `builtin` item, which has nothing to download and must not carry them. | | `permissions` | plugins only: the header's `@permissions` (`null` = no line, legacy). | | `colors` | themes only: `#rrggbb` swatches for the card. | | `language` | string packs only, required: the pack's language tag. | | `license`, `credits` | any kind, optional: its licence (≤ 60 characters, e.g. an SPDX id) and who made, translated or reviewed it (≤ 600). The install screen shows them. | **Built-in items.** `builtin` lists something the app already has, such as one of its area plugins, so the Store can show it with the app's own switch (`StoreView`'s `areas` prop: whether the app has that id, whether it is on, turning it on or off). It has no `url`, `sha256`, `size`, `file`, `permissions`, `colors` or `language`; `Store::stage_*` refuses it, `verify_item` has nothing to verify, and an install link cannot name it. It is data for display only. An app that does not know the id just shows "Built in". **Two files, one source.** An app from before a kind existed refuses the whole signed catalog that lists it (the Store shows as unreachable), so a new kind never goes into the file those apps read. `waybetter store build` writes both from the same items: `catalog.json` (format 2, only `theme`, `plugin` and `strings`; unchanged for old apps) and `catalog.v3.json` (format 3, every kind), each with its own `.sig`; `sign` and `verify` handle the pair. The rules refuse a format 2 file that lists a newer kind. `Store::fetch_catalog` reads `catalog.v3.json` first and falls back to `catalog.json` only when the newer file is not there (a newer file that is there and does not verify is an error, not a fallback). An item of a kind a version does not know is skipped rather than refusing the whole file, so the next new kind costs current apps nothing; every other rule is unchanged. Also refused: unknown fields anywhere, more than 500 items, a catalog over 1 MiB, a signature file over 512 bytes. A malformed item for another app refuses the whole catalog (one file, one set of rules); another app's download hosts are not held to this app's list. ## Signing and keys The signature is Ed25519 over the exact catalog bytes (`verify_strict`), base64 of 64 bytes in `catalog.json.sig`. Apps compile in the public keys (base64 of the raw 32 bytes) and accept a catalog signed by any of them, so a key rotates in three steps: ship an app version that lists the new key next to the old one, re-sign the catalog with the new key, drop the old key in a later version. The private key is a PKCS#8 PEM that never leaves the machine it was made on; the CLI never prints, copies or moves it. ## Tooling: `waybetter store` ```sh waybetter store build --config store.config.mjs [--out public/store] [--base https://waybetter.fyi/store/] waybetter store sign --init --key ~/.keys/store.key --pub keys/store.pub # once; never overwrites waybetter store sign public/store/catalog.json --key ~/.keys/store.key --pub keys/store.pub waybetter store verify public/store/catalog.json --pub keys/store.pub [--pub next.pub] [--app x] ``` `build` takes an app's config module: ```js export default { app: 'x', async items() { return [{ kind: 'plugin', id: 'focus', name: 'Focus', version: '1.0.0', category: 'focus', tags: [], file: 'focus.plugin.js', permissions: ['ui', 'network:none'], path: 'plugins/focus.plugin.js', bytes: source, preview: { path: 'previews/plugin-focus.svg', bytes: svg } }]; }, }; ``` It replaces only that app's items (other apps' items and files stay), writes the files, fills `apps`, `url`, `sha256` and `size`, removes files only the app's previous items used, and checks the whole catalog with the apps' rules before writing anything. `pluginHeader(source)` (from `waybetter/cli/store`) reads a plugin header for the config. `--key` defaults to `$WAYBETTER_STORE_KEY`. Then deploy the folder as it is. ## Install links `waybetter://install?kind=theme|plugin&url=` (the scheme is the app's `StoreConfig::scheme`): https only, a host on the app's list (`DEFAULT_HOSTS`: `waybetter.fyi`, `cdn1.crt.fyi`, and `git.crt.fyi` only for raw files `///raw/…`), default port, no credentials or fragment, at most 2048 characters, no other or repeated parameters. A plugin URL must end in `name.plugin.js`, the name it is saved under. A link only proposes an install: it is queued for the shell (`install-link` event, `install_pending` for links that arrived before the shell listened) and nothing is downloaded until the shell asks. ## Open with, double-click The OS can hand the app a file ("Open with", a double-click, a second launch while the app runs: single-instance forwards its arguments and working directory; macOS an open-URL event with a `file://` URL). `store_commands` keeps each existing file's path under a random id (`Store::receive_file`: a name that says what it is, `kind_for_file_name`, a regular file, within the kind's size cap; at most 8 kept, 15 minutes) and queues a `LinkEvent { opened: { id, name, kind } }` for the shell like a link. The shell asks for it with `install_stage_opened {id}`: native code reads that one file (once; size checked again while reading), validates it exactly as `stage_file` does and stages it, and the shell shows the same install screen as for a dropped file. The command takes an id only: the shell cannot name a path, and an id the OS did not hand over is refused. Nothing installs without the screen. File names: `.plugin.js` plugin, `.strings.json` string pack, `.icons.json` icon pack, any other `.json` a theme. The app declares the types in `tauri.conf.json` (`bundle.fileAssociations`: `js` and `json`, role Viewer, rank Alternate). A plugin is `name.plugin.js` and Windows only knows the last part, so the framework's installer template (`windows/installer.nsi`) lists the app under "Open with" (`OpenWithProgids`) and never makes it the default program for `.js` or `.json` files, unlike Tauri's own association macro. ## Trust model - Only the app's local shell webview may call the install commands; site tabs and web pages never can. The framework has no commands of its own: the app's wrappers call `shell_only` first. - Native code downloads (no redirects, no cookies, size caps), hashes and validates, and stages the exact bytes under a random token; the screen shows those bytes; `Store::commit` writes exactly them after validating them again, and refuses a commit less than 0.6 s after staging. The screen keeps Install disabled for 1 s; Enter never confirms. - Store items: catalog signature first, then the download's size and sha256. A link to a store file that matches the catalog still shows as a link. - Plugins: `plugins::validate_plugin` (file name, ≤ 2 MiB, UTF-8 text without binary bytes, header with `@name` + `@version`, valid `@permissions`, `network:none` refused when the code calls network APIs), then `PluginStore::install` (atomic rename, never enabled on its own; an update keeps the enabled state). Themes: a JSON object with a `name` (≤ 2 MiB), then the app's own theme sanitizer, as for any import. - Permissions are a declaration and a guard rail, not a sandbox. ## Adding the Store to an app Native (`waybetter-host` with the `net` feature): ```rust pub static STORE: StoreConfig = StoreConfig { app_id: "kick", product: "wayBetter for Kick", user_agent: concat!("wayBetter-for-Kick/", env!("CARGO_PKG_VERSION")), scheme: "waybetter", store_base: DEFAULT_STORE_BASE, public_keys: &[include_str!("../keys/store.pub")], allowed_hosts: DEFAULT_HOSTS, }; // setup: app.manage(Store::new(&STORE, &plugins::HOST, app_data_dir)); // deep links: emit_shell(app, EVENT_LINK, store.receive_link(url)). ``` Commands, each starting with `shell_only(&webview)?` and listed only in the shell's capability: `install_pending` → `take_pending`, `install_stage_link {kind,url}` → `stage_link`, `install_stage_file {name,text}` → `stage_file`, `install_stage_opened {id}` → `stage_opened` (a file the OS handed over, by its random id), `install_cancel {token}` → `cancel`, `install_commit {token,enable,themeId}` → `commit(…, run)` where `run` evaluates a plugin start/stop script in the site's tabs, `plugin_remove {file}` → `remove_plugin`, `install_records` → `records`, `install_forget {kind,name}` → `forget`, `store_catalog {refresh}` → `catalog`, `store_stage {item:{kind,id}}` → `stage_item`. Shell (React): ```tsx const controller = createInstallController({ backend: { invoke, listen }, t, site: 'Kick', theme: themeAdapter }); }/> invoke('store_catalog', { refresh })} builtinThemes={…} userThemes={…} onRemove={…} onUse={…} notify={…} relativeTime={…}/> ``` `t` is the app's translator with the framework catalogs merged in (their English is `STORE_MESSAGES`); `{site}` fills in the site's name. Import `waybetter/shell/store.css` and map the `--wb-*` tokens listed at its top to the app's theme on `.wbs, .wbi-backdrop, .wbi-drop`. Pass your own `controls` (Button, SearchField, Segmented, EmptyState, Badge) and `classNames` to match the app's settings UI; the defaults work on their own.