# 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 app on `waybetter_host::app` (its config, site hooks and its own commands), the three capability files and a `tests/security.rs` on `waybetter_host::testkit`, the shell (the framework's shell frame through `createShell`, with the site's pages and a plugins panel), the plugin store, one site-only command, the runtime bundled with `waybetter/build` and a `waybetter.release.json` for `waybetter release`, 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, and the generic native features every app gets (below) tab-bridge.js the tab bridge every tab gets (served by src/bridge.rs) 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/plugins/commands.rs palette commands site tabs report, and the script that runs one 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/bridge.rs the tab bridge script (reports; shortcut/link-drag forwarding opt-in) src/backup.rs settings backup envelope, secret scrubbing, plugin/account sections src/sync.rs encrypted folder sync (Argon2id + XChaCha20-Poly1305 snapshots) src/workspaces.rs saved tab/pane/deck layouts, bounded and well-formed src/devmode.rs developer mode: debounced file watcher, reload order, error positions src/alerts.rs keyword alerts: keywords, snippets, rate limits; native notifications src/media.rs gallery folders (path containment), clipboard history, PNG encoding src/media/clipboard.rs (feature "tauri") opt-in clipboard polling, private copies skipped src/gfonts.rs self-hosted Google Fonts: CSS parsing, URL/woff2 checks, cache format src/fonts.rs (feature "net") font downloads and the font cache src/updater.rs (features "tauri" + "net") signed native updates and the hotfix channel src/store/ install links, download allow-list, signed multi-app store catalog, staging and installs; downloads with feature "net" (docs/STORE.md) src/host.rs (feature "tauri") webview helpers, shell events, system menu src/app.rs (feature "app") the whole app around a site: run_app / App, AppConfig, SiteHooks, the window, shell and tab webviews, the generic commands src/commands.rs the commands the host serves and who may call them; manifest() for build.rs src/testkit.rs capability/caller-check assertions for an app's tests/security.rs 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..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) and the shell frame (React) 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) storeModel.ts storeMessages.ts Store wire types, item states, permission words; wb.store/install/permission.* installController.ts the install screen's state machine (links, drops, store items, confirm) InstallDialog.tsx StoreView.tsx store.css the install screen and the Store view (React peer, --wb-* tokens) frame/ the shell frame: createShell(site) (see "The shell frame" below) packages/waybetter/ the published npm package (runtime + shell as ESM + .d.ts, theme.schema.json, store-catalog.schema.json, the `waybetter store build|sign|verify` and `waybetter release windows|macos` commands, `waybetter/build`) docs/STORE.md the Store: shared catalog format, signing and keys, tooling, trust model, how to add it 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, the shell frame) takes it as a parameter. The React parts (the Store screens, the shell frame) have React, and the frame dockview, as optional peers: the app's copies are used. ## The shell frame `packages/shell/src/frame` (`waybetter/shell/frame`) is the whole shell of a wayBetter app. An app describes its site and gets tabs in the titlebar row, split panes and link drags into the dock, the smart address bar, the new-tab page, the command palette, the account switcher, decks of site columns, workspaces, the developer-mode and sync chips, the gallery/clipboard panel, pane themes, its own panel tab, keyboard shortcuts, overlays that raise the shell above the tab webviews, the one-layout-in-flight `tabs_sync` and session restore: ```tsx import { invoke } from '@tauri-apps/api/core'; // or window.__TAURI__ (withGlobalTauri) import { listen } from '@tauri-apps/api/event'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { createShell, type ShellSite } from 'waybetter/shell/frame'; const site: ShellSite = { name: 'Kick', product: 'wayBetter for Kick', homeUrl, loginUrl, logoutUrl, isSiteUrl, accounts, searchUrl, address, panel: { id, title, component }, newTab, palette, deck, media, themes, overlays, features, start }; const Shell = createShell(site, { native: { invoke, listen, currentWindow: getCurrentWindow }, i18n }); createRoot(root).render(); ``` What the site supplies (`frame/site.ts`, every field documented there): - names and pages: `name` (in messages as `{site}`), `product`, `logo`, `homeUrl`, `loginUrl`, `logoutUrl`, `isSiteUrl`, the account rules, `searchUrl` for typed text; - the address bar: `address(url)` (chips, usually `parseAddress` with an `AddressSite`) and an optional `addressCompleter` (inline completions, e.g. search operators); - slots: `panel` (the app's own tab and its sections), `newTab` (search label, quick links), `palette` (placeholder, the literal search, completions, the panel's sections, the site's pages), `deck` (column kinds with labels and icons, the page each loads, titles, value parsing and completions, column flags and presets, an optional compose dock), `media` (which pages have a composer), `themes` (a `createThemeStore(adapter)`: the app's theme documents, chrome colors via `chromeVars`, pane themes), `overlays` (the app's own dialogs), `start()` (read site settings, call `siteChanged()` when site text changes); - `features`: the native features the app wires up (workspaces, sync, developer mode, plugin palette commands); an app without them shows nothing of them. The frame's words are `wb.*` messages in the framework catalogs (51 languages; English source in `frame/frameMessages.ts`): the app passes its i18n instance with the framework catalogs merged in. Styles come with the components (dockview's, then `shell.css`, `deck.css`, `workspaces.css`, `accountSwitcher.css`, `paneTheme.css`); colors are `--wb-c-*` tokens with Night Ink fallbacks, set by the theme store (the chrome and each pane group) and cached for the first frame: `bootScript()` (`frame/boot.ts`) is the `boot.js` an app serves before its modules (a Vite plugin or a build step writes it). `installPreviewMocks(handlers)` answers native commands from memory, so the real shell renders in a plain browser for development previews and screenshots. Native commands the frame calls: the host's (`tabs_sync`, `tab_navigate`, `tab_action`, `shell_focus`, `shell_raise`, `open_external_url`, `window_system_menu`, `profiles_*`), and for the features an app turns on `settings_get`, `column_configure`, `workspaces_*`, `sync_*`, `dev_*`, `plugins_list`, `tab_run_command`, `media_get`/`gallery_*`/`clip_*`/`tab_attach_image`/ `tab_insert_text`, `theme_scopes_*` and `gfonts_*`, `app_ready`/`update_status`/`apply_update`. It listens to `tab-state`, `tab-open`, `tab-shortcut`, `link-drag`, `open-panel`, `profiles-changed`, `tab-commands`, `dev-event`, `update-status`, `tab-still` and `clipboard-changed`. ## 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. 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, and the host's `commands::SHELL` (`tabs_sync`, `tab_navigate`, `tab_action`, `shell_raise`, `profiles_*`, `i18n_*`, ...) | | `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`). `waybetter_host::testkit` checks this in an app's own tests: `Capabilities::from_dir` reads every capability file, `assert_host_commands` keeps the host's commands shell-only / tab-only, `assert_shell_only` / `assert_tab_only` do the same for the app's, `assert_no_plugin_apis` refuses Tauri plugin JS APIs, and `assert_guarded` / `assert_host_guards` check that each command's first statement checks its caller. ## What the host gives an app - `app` (feature `app`): the whole app. `App::new(AppConfig, hooks).setup(..).run(context, handler)` (or `run_app`) registers single-instance, deep-link (opt-in), window-state, opener, updater (opt-in), process, notification and dialog; makes the app-data folder (0700 on Unix), loads the profiles and the language, runs the app's setup, then builds the window (frameless with the shell's titlebar, or the OS frame), restores its geometry (lost geometry reset), routes helper-window closes (Windows), follows window resizes natively and adds the shell webview (transparent: it can rise above the tabs; macOS needs the `macos-private-api` feature). It serves `commands::SHELL` (`tabs_sync`, `tab_navigate`, `tab_action`, `open_external_url`, `shell_focus`, `shell_raise`, `window_system_menu`, `profiles_get`/`profiles_set`, `i18n_get`/`i18n_set`) and `commands::TAB` (`tab_shortcut`, `tab_link_drag`), each checking its caller first, and dispatches every other command to the app's handler. Tab webviews get their profile's data folder, the app's document-start scripts, navigation/pop-up/load/title/download handling, stills and memory targets; `SiteHooks` is where the app says what only it knows (page scripts, legacy domains, link routing, per-tab state, what a finished site page gets). Helpers: `profiles`, `tab_profile`, `account_seen`, `tr`/`trf`, `i18n_page_script`, `notify`, `open_external`. - `commands`: the host's command list and `manifest(&[..])` for the app's `build.rs`. - `testkit`: see the capabilities section. - `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. - `bridge`: the tab bridge (`SOURCE`, or `script(BridgeOptions)` to leave shortcut and link-drag forwarding off when the shell does not handle them). - `backup` + `sync`: the `.waybetter` settings backup (the app registers its sections and files; secret-looking keys are scrubbed both ways) and encrypted snapshots in a folder the owner syncs. - `workspaces`: validation of saved layouts (slug ids, known tab kinds, http(s) URLs, size caps). - `devmode`: the live-reload watcher (plugins, `custom.css`, one theme file; the app turns the theme document into its own state through a callback). - `alerts`: keyword settings, snippets, `Throttle`, `AlertGate` over any `KeywordMatch`; `alerts::notify` (feature `tauri`) shows a native notification. - `media`: gallery folders and the opt-in clipboard history; `media::clipboard::watch` (feature `tauri`) polls the OS clipboard and never reads a copy a password manager marked private. - `gfonts` + `fonts` (feature `net`): Google Fonts downloaded natively from the two Google hosts only, cached with a sha256 manifest and handed to site pages as bytes (`page_script(&SITE, ..)`). - `updater` (features `tauri` + `net`): the update loop, the signed hotfix channel and `status`/`apply`/`ready` for the app's commands, configured by an `UpdaterConfig`. - `plugins::commands`: palette commands a site tab reports, and `run_script(&SITE, id)`. - `store`: the wayBetter Store for any app from a `StoreConfig` (app id, link scheme, store URL, public keys with rotation, download hosts): `waybetter://install` link parsing, the allow-list, size caps, catalog verification (Ed25519, strict schema, each app sees only its items), `Store` (pending links, staged installs, commit of exactly the staged bytes, records, offline catalog), and with feature `net` no-redirect/no-cookie downloads. The shell half is `waybetter/shell`'s `InstallHost`, `StoreView` and install controller; the tooling is `waybetter store` (docs/STORE.md). - `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 for everything else, and its capability files; its `build.rs` lists its commands through `commands::manifest`, which adds the host's. The npm package adds the app-side tooling: - `waybetter/build`: `buildRuntime({ entry, outfile, catalog, prefixes, ... })` bundles a page runtime into one IIFE with the app's esbuild (`?inline` CSS as text, other CSS dropped, only the runtime's own catalog prefixes compiled in). - `waybetter release windows|macos`: signed native releases and the update manifest from the app's `waybetter.release.json` (product and file names, key folder, CDN and API URLs, an optional manifest validator). Windows: the signed NSIS build, the installer and `.sig` in `/upload`, `latest.json` (other platforms of the version kept); macOS: signs a universal build's updater bundle and adds both darwin platforms. It never uploads: it prints the steps. The key is read only into the build's environment or handed to the Tauri signer by path. ## 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 = ["app", "net", "macos-private-api"]` enabled by the app's desktop feature (and as a build-dependency for `commands::manifest`). - TypeScript: relative imports of `waybetter/packages/*/src/*.ts`: the X site for the shell frame (`src/shell/site.tsx`), the page runtime's adapter and a few X modules. Its Vite config dedupes React, dockview and the Tauri API (the frame's imports use the app's copies) and serves `bootScript()` as `/boot.js`. 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 app feature (slow: builds Tauri and its plugins) 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+.