An app that does not know a catalog item's kind refuses the whole signed file, so the icons and builtin kinds can never go into catalog.json. `waybetter store build` now writes catalog.json (format 2: theme, plugin, strings only; what every app reads today) and catalog.v3.json (format 3: every kind) from the same items; `sign` and `verify` handle the pair; the rules refuse a format 2 file that lists a newer kind (CLI, JSON Schema, host). The host reads catalog.v3.json first and falls back to catalog.json only when the newer file is not there (a newer file that does not verify is an error). An item of a kind a version does not know is skipped instead of refusing the file, so the next new kind costs current apps nothing.
12 KiB
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.
{ "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
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:
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=<percent-encoded https 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 /<owner>/<repo>/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_onlyfirst. - 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::commitwrites 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:nonerefused when the code calls network APIs), thenPluginStore::install(atomic rename, never enabled on its own; an update keeps the enabled state). Themes: a JSON object with aname(≤ 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):
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):
const controller = createInstallController({ backend: { invoke, listen }, t, site: 'Kick', theme: themeAdapter });
<InstallHost controller={controller} t={t} site="Kick" setOverlay={setOverlay} openSection={openSettings}
renderThemePreview={theme => <Preview theme={theme}/>}/>
<StoreView t={t} site="Kick" controller={controller} load={refresh => 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.