12 KiB
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. License: GPL-3.0-or-later.
npm install waybetter # page runtime, shell, CLI tools
npm create waybetter # scaffold a plugin, a theme or a whole app
Links: source · npm · 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.
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)
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.<global> 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)
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:
{ "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
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.
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 andopen_panel. Everything else (tab layout, settings, plugins, backup, install) is callable from the localshellwebview only. SiteSpec::is_originis 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://installlink 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::testkitlets yourtests/security.rsread 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-scanandwaybetter build-envkeep a build free of the builder's paths and names;waybetter releaserefuses 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
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. 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 | x.com | Tabs, split panes, real accounts side by side, themes, plugins, drafts and fixes for X's own bugs. |