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.
161 lines
12 KiB
Markdown
161 lines
12 KiB
Markdown
# 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=<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):
|
|
|
|
```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 });
|
|
<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.
|