waybetter/README.md
CRT b972524888 create-waybetter: npm create waybetter scaffolds plugins (JS/TS), themes and whole apps
- plugin: *.plugin.js with the store header and start/stop on api.scope, storage, observe,
  onKey, the UI kit (toast with action, ui.h settings dialog) and api.theme/--wb-* vars;
  --ts makes a TypeScript project typed by the waybetter package (build keeps the header first)
- theme: JSON with "$schema" -> theme.schema.json, palette derived like themeFromColors,
  font roles (family, tweet), presets
- app: Tauri 2 + waybetter-host + runtime + shell models: SiteSpec, SiteAdapter, shell/tab-chrome/
  site-tab capabilities, tabs_sync, plugin store, a site-only command; presets for youtube.com,
  kick.com, twitch.tv with placeholder selectors; host crate via submodule, path or git
- tests generate into temp dirs: headers via parse_meta (new plugin_meta example), plugin code
  against createCorePluginApi's members, themes against the schema, app TS with tsc against the
  built package, app Rust with cargo metadata/check/test
- wired into bun run check (typecheck incl. checkJs, build:packages, test:create) and pack:check
2026-09-28 23:17:32 +07:00

237 lines
12 KiB
Markdown

# waybetter
The framework behind the wayBetter apps: apps that extend the apps you already use in place, the
way BetterDiscord extends Discord. The first app is **wayBetter for X**; more are being built on
the same core.
```sh
npm install waybetter
```
[npm](https://www.npmjs.com/package/waybetter) · [waybetter.fyi](https://waybetter.fyi)
A wayBetter app is one native window (Tauri 2, multiwebview) with:
- a trusted local **shell** webview: tab strip, split panes (dockview), address bar, titlebar and
window controls, command palette, account switcher, the app's own settings;
- one native child webview per browser **tab**, positioned over the pane rectangles the shell
reports (`tabs_sync`), each in an account **profile** with its own WebView data directory;
- a **page runtime** injected into tabs that are on the app's site: shared DOM/request hubs,
plugins, themes, split-view rail hiding, link routing and account reporting.
This repository holds the parts that do not depend on which site the app extends. An app is mostly
a **site adapter** plus its own features.
## One system for every app
Every wayBetter app is the same system: the Tauri window above (shell + page runtime) plus one
small adapter. That holds for websites like x.com and just as much for services that ship their
own Electron desktop client, such as WhatsApp or Discord: wayBetter runs their web app in its own
light Tauri window instead of the heavy client, and gives it the same tabs, split panes, real
accounts side by side, themes, plugins and signed updates. There is no second kind of host.
License: GPL-3.0-or-later.
## Projects using wayBetter
| Project | Target | What it is |
| --- | --- | --- |
| [wayBetter for X](https://waybetter.fyi/twttr/) | x.com (website) | Desktop app around the live X: tabs, split panes, real accounts side by side, themes, plugins, drafts and fixes for X's own bugs. The first wayBetter app. |
Built something on wayBetter? Add it to this list with a pull request: name and link, what it extends, one line on what it does.
## Build your own
```sh
npm create waybetter plugin # a *.plugin.js (or --ts: a TypeScript project) on the real plugin API
npm create waybetter theme # a theme JSON with "$schema", a derived palette and font roles
npm create waybetter app -- --site kick.com # a whole app: Tauri 2 + waybetter-host + runtime + shell
```
`create-waybetter` (in `packages/create-waybetter`) asks for what it needs in a terminal, and
takes every answer as a flag (`--help`). The app skeleton is this README in code: the native
`SiteSpec`, the page `SiteAdapter`, the three capability files, a shell that reports the tab
rectangle (`tabs_sync`), the plugin store and one site-only command, with presets for
youtube.com, kick.com and twitch.tv and every site selector marked as a placeholder. Its tests
generate each kind into a temp folder and check it against the real contracts: plugin headers with
`plugins::parse_meta`, plugin code against `createCorePluginApi`'s members, themes against
`theme.schema.json`, the app's TypeScript with `tsc` against the built package and its Rust with
`cargo check`/`cargo test` against this crate.
## Layout
```
crates/waybetter-host/ Rust: security policy, profiles, plugin store, hotfix, updates, tab payloads
src/site.rs SiteSpec + AccountRules (the native half of the adapter)
src/policy.rs webview labels, origin checks, tab rect clamp
src/profiles.rs profiles, per-profile data dirs, native-owned accounts
src/plugins.rs *.plugin.js store, custom CSS, bundled-plugin upgrades
src/hotfix.rs signed data-only hotfix channel (minisign)
src/update.rs silent-update scheduling and crash handshake
src/tabs.rs TabLayout/TabState/OpenRequest, link modes, shortcut and drag validation,
deck ColumnConfig bounds and unread caps
src/host.rs (feature "tauri") webview helpers, shell events, system menu
packages/runtime/src/ page runtime core (TypeScript, no dependencies)
site.ts SiteAdapter (the page half of the adapter) + configureRuntime
domHub.ts netHub.ts one MutationObserver/frame, one fetch/XHR hook per page
pluginApi.ts core of window.<Runtime>.api for plugins (incl. ui and theme)
ui.ts plugin UI kit: toasts, alert/confirm/prompt, dialogs (shadow DOM, themed)
themeApi.ts the active theme as values + --wb-* CSS variables (api.theme)
themeTokens.ts OKLab color model: maps the site's palette onto a theme's axis
paneNav.ts split view: park the site's navigation rail in redundant panes
column.ts deck columns: hide site chrome, fill the width, filters, unread count
linkRouting.ts divert in-page clicks to a pane/tab/browser per setting
accountReport.ts report the signed-in account of this tab's profile
packages/shell/src/ shell core (TypeScript, no dependencies)
addressModel.ts address bar chips (site pages named by the adapter)
paneModel.ts beside-pane reuse, redundant rails, navRedundant for tabs_sync
deckModel.ts decks of site columns: bounded storage model, paging layout, focus
linkDrop.ts dropping links into the dock
profileModel.ts profiles and account labels
palette.ts command palette scoring/ranking
updateModel.ts titlebar update chip
windowControls.ts caption buttons, resize grips, system menu (Tauri API injected)
chrome.ts titlebar/toolbar/resize-border sizes (match policy.rs)
packages/waybetter/ the published npm package (runtime + shell as ESM + .d.ts, theme.schema.json)
packages/create-waybetter/ `npm create waybetter`: plugin, theme and app scaffolds (no dependencies)
```
Both TypeScript packages are plain source (`.ts`, ESM, `type: module`) with no runtime
dependencies, so an app can bundle them with esbuild/Vite and run their tests with Node's type
stripping. Anything that needs the Tauri API (window controls) takes it as a parameter.
## The site adapter
A site adapter is three small, explicit things.
### 1. Native: `SiteSpec` (Rust)
```rust
use waybetter_host::site::{AccountRules, SiteSpec};
fn kick_handle(value: &str) -> bool {
(3..=25).contains(&value.len()) && value.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
pub static SITE: SiteSpec = SiteSpec {
product: "wayBetter for Kick",
hosts: &["kick.com"], // exact https origin(s) of site tabs
runtime_global: "WayBetterKick", // window.<global> of the page runtime
accounts: AccountRules {
valid_handle: kick_handle,
avatar_prefixes: &["https://files.kick.com/"],
max_name_chars: 50,
max_avatar_len: 512,
},
};
```
`SiteSpec::is_origin` is the only definition of "a site tab". Site-only commands check
`policy::require_site_tab(&SITE, label, url)` (or `host::site_tab_only`), which also requires a
`tab-*` label.
### 2. Page: `SiteAdapter` (TypeScript)
```ts
import { configureRuntime, type SiteAdapter } from 'waybetter/packages/runtime/src/site.ts';
export const KICK: SiteAdapter = {
product: 'wayBetter for Kick',
hosts: ['kick.com'],
runtimeGlobal: 'WayBetterKick',
heavyPath: null, // pages where offOnHeavy DOM features pause
apiMarks: ['/api/'], // request URLs the net hub stages may see
xhrMarks: [],
plugins: { name: 'WayBetterKick', product: 'wayBetter for Kick', storagePrefix: 'wbk-plugin:',
styleDataKey: 'wbkPlugin', toastId: 'wbk-toast', toastBackground: '#53fc18',
itemSelector: '[data-chat-entry]', chordKeys: [] },
rail: { prefix: 'wbk', rail: '#sidebar-wrapper', anchor: 'a[href="/"]', main: 'main',
modalOpen: '[role="dialog"]', menuOpen: '[role="menu"]',
paint: { width: 64, background: '#0b0e0f', line: 'rgba(255,255,255,.12)', accent: '#53fc18', rtl: false } },
account: { read: readKickAccount, signedOut: root => !!root.querySelector('[data-test="login"]') },
links: { configKey: 'site', clickedUrl: kickClickedUrl },
themeSources: { dark: KICK_DARK_AXIS },
};
configureRuntime(KICK);
```
Each runtime module takes only its piece (`installPaneNav(window, KICK.rail!)`,
`installLinkRouting(window, KICK.links!)`, `installAccountReport(window, KICK.account!, invoke)`,
`createCorePluginApi(window, KICK.plugins)`), so a site without a rail simply does not install it.
### 3. Capabilities (Tauri JSON, keyed by webview label)
The security model is the same for every app and must stay keyed by webview label:
| webview | may call |
| --- | --- |
| `shell` (local assets only) | app, settings, vault, tab management (`tabs_sync`, `tab_navigate`, `tab_action`, ...) |
| `tab-*` (any site) | only tab chrome about itself: `tab_report`, `tab_shortcut`, `tab_link_drag` |
| `tab-*` on the exact site origin | the app's short, reviewed list of site-only commands |
| other local webviews (`popover-*`, ...) | their own narrow capability |
```json
{ "identifier": "tab-chrome", "webviews": ["tab-*"], "local": false,
"remote": { "urls": ["https://*/*", "http://*/*"] },
"permissions": ["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"] }
```
```json
{ "identifier": "site-tab", "webviews": ["tab-*"], "local": false,
"remote": { "urls": ["https://kick.com/*"] },
"permissions": ["allow-open-panel"] }
```
Native code re-checks every one of these (the capability file alone is not the boundary): the
caller's label, the webview's own URL (never the page's claim), payload bounds. A tab can speak only
about itself; the account it reports is recorded only for its own profile, only while it is on the
site, and a shell save can never change it (`ProfileSettings::keep_accounts_from`).
## What the host gives an app
- `policy`: `SHELL_LABEL`, `tab_id`, `require_local`, `require_site_tab`, `clamp_tab_rect` (tabs never
cover the titlebar or, while restored, the resize band).
- `profiles`: `ProfileSettings` (validated, default profile required, profiles with open tabs cannot be
removed), `data_dir` (ids cannot escape `profiles/`), `orphan_dirs`, `store_identifier` (macOS).
- `plugins`: `PluginStore` with a `PluginHost` (runtime global, origin, product, bundled plugins).
Bundled plugins are written disabled and upgraded only while untouched.
- `hotfix`: minisign-verified, schema-checked, monotonic, expiring data; delivered frozen as data.
- `update`: check schedule with jitter, crash handshake (automatic apply off after two failed boots).
- `tabs`: shell payloads and validation; `deliver_script` hands JSON to a runtime member or parks it.
- `host` (feature `tauri`): `shell_only`, `tab_only`, `site_tab_only`, `emit_shell`, `tab_webview`,
`eval_in_site_tabs`, `navigate_tab`, `tab_action`, `forward_shortcut`, `forward_link_drag`,
`read_json`/`write_json`, `remove_profile_dirs`, `prune_profile_dirs`, `migrate_identifier`,
`system_menu`.
The app keeps its own `#[tauri::command]` wrappers, so IPC command names, the build-time command
manifest and the capability files stay in the app.
## Consuming it
wayBetter for X adds this repository as a git submodule at `waybetter/` and consumes it from source:
- Rust: a path dependency, `waybetter-host = { path = "../../../waybetter/crates/waybetter-host" }`,
with `features = ["tauri"]` enabled by the app's desktop feature.
- TypeScript: relative imports of `waybetter/packages/*/src/*.ts` from a few adapter modules. The
packages are not Bun workspace members of the app on purpose: the app's server CI clones without
submodules and runs `bun i`, which fails when a declared workspace package is missing; and Node's
type stripping refuses `.ts` files under `node_modules`, while sources outside it work with Bun's
isolated linker on Windows.
## Scripts (no CI)
```sh
bun install # typescript + @types/node for the checks
bun run typecheck # tsc over both packages and their tests, and create-waybetter (checkJs)
bun run test # node --test (runtime + shell), cargo test (headless host), create-waybetter
bun run test:ts
bun run test:rust
bun run test:create # scaffolds into temp dirs; its cargo check of an app is ~1 min cold
# (WAYBETTER_SKIP_CARGO_CHECK=1 skips it; needs the local crate cache)
bun run check # typecheck + build:packages + test
bun run check:tauri # cargo check with the tauri feature (slow: builds Tauri)
bun run build # typecheck + cargo build
bun run pack:check # npm pack --dry-run of waybetter and create-waybetter
```
Requires Bun 1.4.2+, Node 24+ and Rust 1.88+.