# waybetter A framework for desktop apps that run one website (or a web app you would otherwise use through a heavy Electron client) in a small native window and extend it in place, the way BetterDiscord extends Discord. You get tabs, split panes, several signed-in accounts side by side, themes, plugins, a signed store for them, backups and updates. You write a small adapter that says what the site is. The first app built on it is [wayBetter for X](https://git.crt.fyi/CRT/waybetter-for-x). License: GPL-3.0-or-later. ```sh npm install waybetter # page runtime, shell, CLI tools npm create waybetter # scaffold a plugin, a theme or a whole app ``` Links: [source](https://git.crt.fyi/CRT/waybetter) · [npm](https://www.npmjs.com/package/waybetter) · [waybetter.fyi](https://waybetter.fyi) ## The three layers A wayBetter app is one Tauri 2 window with several webviews. The framework has one piece for each layer and does not need to know which site you wrap. | Layer | Where | What it does | | --- | --- | --- | | Native host | `crates/waybetter-host` (Rust) | Builds the window, the trusted `shell` webview and one child webview per tab, keeps profiles (each with its own WebView data folder), the plugin store, settings, backups, encrypted sync, updates, fonts, the store install rules, and decides which webview may call which command. | | Page runtime | `packages/runtime` (TypeScript, no dependencies) | Injected into tabs that are on the site's exact origin: one DOM hub and one fetch/XHR hub, the plugin API and its themed UI kit, the theme engine, split-view navigation, link routing, account reporting, deck columns, string packs. | | Shell | `packages/shell` (TypeScript and React) | The local UI around the tabs: `createShell(site)` gives tabs in the titlebar, split panes, address bar, command palette, account switcher, decks and workspaces; `createPanel(site)` gives the app's own settings tab (appearance, theme builder, plugins, store, backup, developer mode). | Both TypeScript packages ship as the npm package `waybetter` (plain ESM with `.d.ts`). The Rust crate is used from source, as a git submodule or a path dependency. Details of every module are in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). ## A minimal adapter An adapter is three small things. This is what `npm create waybetter app` writes for `kick.com` (selectors are placeholders you replace after looking at the real site). **1. Native: which webviews are site tabs, and what an account looks like** (`src-tauri/src/site.rs`) ```rust use waybetter_host::site::{AccountRules, SiteSpec}; pub fn valid_handle(handle: &str) -> bool { (3..=25).contains(&handle.len()) && handle.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): the only definition of a site tab runtime_global: "WayBetterForKick", // window. of the page runtime accounts: AccountRules { valid_handle, avatar_prefixes: &["https://files.kick.com/"], max_name_chars: 50, max_avatar_len: 512, }, }; ``` **2. Page: what the runtime needs to know about the markup** (`src/site.ts`, shortened) ```ts import type { SiteAdapter } from 'waybetter/runtime/site'; export const SITE: SiteAdapter = { product: 'wayBetter for Kick', hosts: ['kick.com'], runtimeGlobal: 'WayBetterForKick', heavyPath: null, apiMarks: ['/api/'], // request URLs the net hub may inspect xhrMarks: [], plugins: { name: 'WayBetterForKick', product: 'wayBetter for Kick', storagePrefix: 'wb-kick-plugin:', styleDataKey: 'wbKickPlugin', toastId: 'wb-kick-ui', toastBackground: '#53fc18', itemSelector: '[data-chat-entry]', chordKeys: [] }, account: { read: readAccount, signedOut: root => !!root.querySelector('button[data-testid="login"]') }, links: { configKey: 'site', clickedUrl }, }; ``` **3. Capabilities: who may call what** (`src-tauri/capabilities/*.json`, keyed by webview label). The scaffold writes `shell`, `tab-chrome` and `site-tab`; the last one is granted only on the site's own URLs: ```json { "identifier": "site-tab", "webviews": ["tab-*"], "local": false, "remote": { "urls": ["https://kick.com/*"] }, "permissions": ["allow-site-ready", "allow-open-panel"] } ``` The app itself is `waybetter_host::app` configured with an `AppConfig` and a `SiteHooks` implementation (what only your site knows: page scripts, legacy domains, link routing). Anything else the app needs is an ordinary `#[tauri::command]` in your crate. ## Start a project ```sh npm create waybetter plugin -- --name "Hide Trends" npm create waybetter theme -- --name "Midnight Mint" --accent "#3ddc97" npm create waybetter app kickapp -- --site kick.com --name "wayBetter for Kick" --yes ``` The app scaffold adds the framework as a git submodule at `waybetter/` by default (`--host-source path` and `--host-source git` are the alternatives). Its next steps, as the tool prints them: `git init && git submodule add https://git.crt.fyi/CRT/waybetter.git waybetter`, then `npm install`, `npm run build`, `npm run tauri dev`, then replace the placeholder selectors in `src/site.ts` and `src-tauri/src/site.rs`. Flags: `npm create waybetter -- --help`. See [packages/create-waybetter](packages/create-waybetter/README.md). ## The dev loop In a scaffolded app, `npm run dev` runs `waybetter dev --watch "node scripts/build.mjs --watch"`: it starts your watchers, waits for their first build and runs `tauri dev`. In debug builds the page runtime file is read from disk and watched; when it changes, every tab webview is rebuilt on its current URL (about a second from save to visible, the route is kept, the scroll position is not). The shell reloads through the frontend dev server. Rust, `tauri.conf.json`, capabilities and bundled plugin sources need a restart. `npm run dev:stable` adds `--no-watch` so a Rust file saved elsewhere does not restart the app. Release builds contain none of this. There is also a developer mode in the panel (Settings, Developer) that reloads installed plugins, custom CSS and one theme file in every tab as you save. ## Security model The site's page is not trusted, and neither is a plugin. The rules: - Capabilities are keyed by webview label and each command re-checks its caller in Rust: the capability file is not the boundary. A `tab-*` webview may only report about itself (`tab_report`, `tab_shortcut`, `tab_link_drag`); on the site's exact origin it also gets the app's short, reviewed list of site commands and `open_panel`. Everything else (tab layout, settings, plugins, backup, install) is callable from the local `shell` webview only. - `SiteSpec::is_origin` is the single definition of "a site tab": exact https origin, no credentials in the URL. - The account a tab reports is recorded only for that tab's own profile, only while it is on the site, validated by your `AccountRules`, and a settings save can never change it. - Installs (themes, plugins) come from a dropped file, a `waybetter://install` link or the signed store, and are written only after the owner confirms in the shell. The store catalog is verified with Ed25519; downloads use an allow-list, no redirects, no cookies and size caps. Plugins may declare `@permissions`; the runtime gives each an API view that throws on undeclared members. That is a guard rail, not a sandbox. A plugin without the line is labelled legacy and gets the full API. - Backups and sync snapshots never contain cookies, WebView profile folders, tokens or signing keys. Sync is encrypted with the owner's passphrase (Argon2id and XChaCha20-Poly1305). - `waybetter_host::testkit` lets your `tests/security.rs` read every capability file and assert that host commands stay shell-only or tab-only, that site commands are granted only on the site's URLs, and that each command's first statement checks its caller. The scaffold includes such a test. - `waybetter privacy-scan` and `waybetter build-env` keep a build free of the builder's paths and names; `waybetter release` refuses to release an artifact with a hit. ## Optional command groups Beyond the commands every app gets, the host serves groups of shell-only commands that an app turns on in its `AppConfig` (or, for the state-free ones, simply grants in its shell capability). Each group is a list in `waybetter_host::commands`, each wrapper checks its caller first, and `waybetter_host::testkit` asserts both in your `tests/security.rs`. | Group | Turn on with | What it serves | What the app supplies | | --- | --- | --- | --- | | `STORE` | `AppConfig::with_store(&STORE)` | install links, dropped files, the signed Store, the install screen | its `StoreConfig` (app id, hosts, public keys) | | `MEDIA` | `with_media()` | gallery folders, previews, the opt-in clipboard history and its watcher | attaching an image to the site's composer, if it has one (`media_commands::gallery_file` and `clip_image` give it the checked bytes) | | `GFONTS` | `with_fonts()` | Google Fonts install, remove, preview (needs the `net` feature) | `SiteHooks::fonts_changed` (give the tabs their theme again) | | `UPDATE` | `with_updates(&UpdaterConfig)` | update status, check now, apply, the update loop and the hotfix channel | the channel, its key and the download page | | `SYNC` | `with_sync()` | encrypted folder sync | `SiteHooks::backup_sections` and `backup_section_names` | | `DEV` | `with_dev()` | developer mode: live reload of plugins, custom CSS and a theme file | `SiteHooks::dev_theme_apply` and `dev_theme_end` | | `WORKSPACES`, `BACKUP` | always served | saved layouts; settings backup (collect, export, open, apply) | for backup: `backup_drafts` and `backup_apply` (how each imported section is applied) | What is still the app's to write today: keyword alerts' commands around `waybetter_host::alerts`, the theme commands around `waybetter_host::theme` (wayBetter for X names them `x_*`), a popover webview and anything else that is specific to the site. They are on the list to move. ## Repository layout ``` crates/waybetter-host/ Rust host crate (features: tauri, net, app) packages/runtime/ page runtime (published inside `waybetter`) packages/shell/ shell models, shell frame, panel, store screens (published inside `waybetter`) packages/waybetter/ the npm package: built runtime and shell, JSON Schemas, locales, and the `waybetter` command (store, release, i18n, strings, privacy-scan, build-env, dev) packages/create-waybetter/ `npm create waybetter` docs/ ARCHITECTURE.md, STORE.md, STRING-PACKS.md dev-tools/ harnesses for a running or built app (not published) ``` ## Tests ```sh bun install bun run typecheck # tsc over both packages, their tests and create-waybetter bun run test:ts # node --test: runtime and shell bun run test:rust # cargo test of the host crate (headless, feature net) bun run test:create # scaffolds each kind into temp folders and checks them against the real contracts ``` `test:create` also runs `cargo check` on a generated app (about a minute cold); `WAYBETTER_SKIP_CARGO_CHECK=1` skips that step. Requires Bun 1.4.2+, Node 24+ and Rust 1.88+. ## Contributing Issues and pull requests go to [git.crt.fyi/CRT/waybetter](https://git.crt.fyi/CRT/waybetter). Keep the split: generic mechanisms belong here, site-specific ones in the app. If you built something on wayBetter, add it to the list below with a pull request. | Project | Target | What it is | | --- | --- | --- | | [wayBetter for X](https://git.crt.fyi/CRT/waybetter-for-x) | x.com | Tabs, split panes, real accounts side by side, themes, plugins, drafts and fixes for X's own bugs. |