waybetter/docs/STORE.md
CRT 8a6ef635d2 Store: two catalog files from one source, so apps from before a kind keep a working Store
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.
2026-10-01 14:41:29 +07:00

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.

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_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):

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.