waybetter/docs/ARCHITECTURE.md

39 KiB

Architecture reference

Every module of the host, the runtime and the shell, and what an app supplies. The overview is in the README; this is the long form.

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 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

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/settings.rs           the shell's settings (home page, where links open, session restore) and the
                            page's feature flags, as validated data and the scripts that deliver them
  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
                            (tabs, profiles, language, plugins and custom CSS, feature flags, settings)
  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.<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
  siteRuntime.ts            the runtime's composition: the IPC bridge, flag-controlled parts with a deck
                            column's overrides, the runtime object under its window names, native queues
  pageStrings.ts            the page's language: English built in, native deliveries checked as data
  stringPacks.ts            string packs (docs/STRING-PACKS.md): pack and inventory formats, validator,
                            ordered engine, coverage, drift report
  stringPackHook.ts         the per-site catalog hook adapter, its self-check, a webpack chunk array hook
  textNet.ts                DOM safety net for labels no catalog hook reaches
  panelButton.ts            the way into the panel from the page (navigation entry or floating button)
  commandReport.ts          the palette command report and developer-mode start errors
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)
  panel/                    the panel: createPanel(site), the app's own tab (see "The panel" below)
  installerMessages.ts      the Windows installer's words (wb.installer.*)
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`,
                            `waybetter release windows|macos`, `waybetter i18n check|extract`,
                            `waybetter privacy-scan` and `waybetter build-env` commands, `waybetter/build`, locales/ (51 catalogs and translator notes),
                            windows/ (the installer template and its language files))
dev-tools/                  development harnesses for a running or built app (perf probes, close and
                            stress checks); not published (dev-tools/README.md)
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:

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(<Shell/>);

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 panel

packages/shell/src/panel (waybetter/shell/panel) is an app's own tab in the shell: a home page, Appearance (the theme gallery, customization, account and pane themes, downloaded fonts, theme import/export) and the Theme Builder (undo/redo, a palette from three colors, a contrast report, a font per role with the Google Fonts browser), plugins and custom CSS, the Store, Make (example plugins, the plugin UI kit, the theme API, bundled docs), accounts, Power User (deck settings), settings (language, links and session, keyword alerts, the media panel), backup and sync, developer mode, updates, and a search across every setting. It lives in the shell package, next to the frame it renders in: it uses the frame's stores (profiles, decks, themes, sync, developer mode), its native bridge and its language, so createShell comes first.

import { createPanel, type PanelSite } from 'waybetter/shell/panel';

const panel: PanelSite = { brand: { name: 'wayBetter', logo }, home, themes, sections, order, make,
  store, languages, settings, power, backup: true, updates: true };
const Panel = createPanel(panel);
const Shell = createShell({ ...site, panel: { id: 'app', title: 'wayBetter', component: Panel } }, options);

What the app supplies (panel/site.ts, every field documented there):

  • brand and home: the header and the home page's words (kicker, title, lede, chips, actions, highlight cards, the About block), version() for its chip;
  • sections and order: its own sections (a label, an icon path, a component that gets notify and openSection; searchable ones also show in the settings search), placed among the built-in ones. X adds Tweaks and Drafts;
  • themes: its theme store and catalog (createThemeStore, createThemeCatalog), accent swatches, the preview component (a miniature of the site drawn from a theme document, used by the gallery and the builder), the site font's name, the palette a draft starts from, and for Make's theme view resolved(choice, mode) with the site's own display modes;
  • make: example plugins, an example theme file and bundled Markdown docs;
  • store: the install controller (the Store section, and removing a plugin from the list);
  • languages: the language list and state (createLanguage, frame/language.ts);
  • settings, power, cssExamples: keyword alerts, extra settings rows, which column flags and which memory measure Power User lists, placeholder CSS with the site's selectors.

A built-in section shows only when the app serves what it needs: Appearance, the Theme Builder and the Store with themes (and store), Make with make, Power User with site.deck, backup with backup (its sync half with the frame's sync feature), Developer with the frame's developer feature, Updates with updates. An app with nothing but plugins gets Home, Plugins, Accounts and Settings (this is what npm create waybetter app generates).

Building blocks for the app's own sections: the panel's controls (Section, Row, Toggle, Segmented, Select, Slider, ColorField, Button, Badge, ...; panel/controls), the feature toggles (useFeatures + FeatureGroups over features_get/set: the app names its flags and groups, its catalog has features.<flag>.label and .description), and compareLines.

The panel's words are wb.* messages too (English source in panel/panelMessages.ts, translator notes in locales/en.meta.json). Three families of keys stay the app's, by convention, because they name its own things: plugin.<file>.name|description (its bundled plugins), theme.<id>.description (its built-in themes) and features.* (its tweaks).

Native commands the panel calls: the host's plugins_list, plugin_set_enabled, plugins_open_folder, custom_css_get/set, features_get/set, settings_get/set, profiles_* and open_external_url, and for what the app turns on alerts_get/set, media_get/set/gallery_add_folder/clip_remove, backup_*, sync_*, dev_*, the theme store's commands and gfonts_*, install_records/install_forget/plugin_remove/store_catalog, app_ready/update_status/apply_update.

The Google Fonts list the font browser shows is an offline snapshot (panel/fonts/googleFontsCatalog.json; packages/shell/scripts/google-fonts-catalog.mjs regenerates it). The app never fetches the list, only the font files the owner picks (natively).

The site adapter

A site adapter is three small, explicit things.

1. Native: SiteSpec (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)

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.

What every runtime entry does the same way is composed by runtime/siteRuntime: pageInvoke(window, product) (the IPC bridge, read at call time), createFeatureFlags({ defaults, installers, apply }) (parts that install on enable and dispose on disable; a deck column's flag overrides sit on top of the owner's) and installSiteRuntime({ win, site, version, plugins, strings, features, theme, fonts, pane, links, column, extra, queues }), which builds the runtime object (features.apply, i18n.set, links.configure, pane.set, fonts.provide, column.set, open(), api, withPlugin, devError), publishes it as window.wayBetter and the app's own names, takes over what native code queued before it existed and starts the palette command report. createPageStrings (runtime/pageStrings) is the page's language and installPanelButton (runtime/panelButton) keeps the way into the panel on the page: the adapter's mountNav puts an entry into the site's own navigation, and where the page has none a floating button stands in.

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) the app's own commands and the host's commands::SHELL (tabs_sync, tab_navigate, tab_action, shell_raise, profiles_*, i18n_*, plugins_*, custom_css_*, features_*, settings_*, ...)
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, and the host's commands::SITE (open_panel)
other local webviews (popover-*, ...) their own narrow capability
{ "identifier": "tab-chrome", "webviews": ["tab-*"], "local": false,
  "remote": { "urls": ["https://*/*", "http://*/*"] },
  "permissions": ["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"] }
{ "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_site_only(command, &SITE) checks that a site command is granted only on the site's own URLs, 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_ready, tab_still_ready, shell_raise, window_system_menu, profiles_get/profiles_set, i18n_get/i18n_set, plugins_list, plugin_set_enabled, plugins_open_folder, custom_css_get/custom_css_set, features_get/features_set, settings_get/settings_set), commands::TAB (tab_shortcut, tab_link_drag) and commands::SITE (open_panel), each checking its caller first, and dispatches every other command to the app's handler. It keeps the page state those commands read and save (PageState: the plugin store in app data plugins, made from the PluginHost in AppConfig::new(site, title, plugins); the feature flags in features.json; ShellSettings in settings.json): the app reads it with page(app), gets a loaded document's scripts from page_scripts(app) (flags, links, custom CSS, plugins) and writes it with set_features, set_settings and set_custom_css (a backup import). 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. To show the first window only after restored panes are placed, opt in on both sides: AppConfig::defer_window_show = true and ShellSite.deferWindowShow = true, and grant the shell allow-shell-ready. The default still shows the window immediately. An opted-in app shows it after initial tabs_sync, with a three-second fallback if shell startup fails.
  • commands: the host's command lists (SHELL, TAB, SITE) and manifest(&[..]) for the app's build.rs.
  • settings: ShellSettings (home page, where links to other sites and to the site's own pages open, session restore; missing fields take the defaults), links_script, and the feature flags' validation (a small map of booleans) and features_script.
  • 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.

Besides the commands above, the host serves optional groups of shell-only commands that an app turns on with AppConfig::with_* (see "Optional command groups" in the README): commands::STORE, MEDIA, GFONTS, UPDATE, SYNC, DEV, WORKSPACES and BACKUP, with the SiteHooks methods that carry what only the app knows (fonts_changed, backup_sections, backup_section_names, backup_drafts, backup_apply, dev_theme_apply, dev_theme_end). The app keeps its own #[tauri::command] wrappers for everything site-specific, 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 <outDir>/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.
  • waybetter build-env -- <command> and waybetter privacy-scan: nothing an app publishes may name the person who built it. A Rust binary embeds the absolute paths of its sources (the Cargo registry, the rustup toolchain, the workspace: about 1,400 strings holding the account name in one app), so build-env runs a build with those roots remapped to /home, /src, /cargo, /rustup and /target (--remap-path-prefix appended to the flags already set, /PDBALTPATH:%_PDB% on MSVC, -ffile-prefix-map for C sources on gcc and clang). MSVC has no prefix map, so there the C and C++ a build script compiles (a CMake library, the cc crate) lose the same roots through /d1trimfile:<root> in the CL variable, and any PDB they link through /PDBALTPATH in LINK; cl.exe and link.exe read both themselves, whatever runs them: waybetter build-env -- tauri build, on every platform. privacy-scan is the proof: it searches files, binaries (UTF-8, UTF-16), archives (npm tarballs, zip, tar, .deb, DMG; NSIS installers and AppImages through 7-Zip), git-tracked files and, with --history, every blob, path, commit message and identity of a repository for the terms in a file kept outside every repository ("forbiddenTerms" in waybetter.release.json, or WAYBETTER_FORBIDDEN_TERMS) plus the machine's account name, and prints hits masked (m***). waybetter release builds with build-env and refuses to release an artifact with a hit or one it could not open. The .pdb next to a Windows binary still holds every path: never publish it.
  • waybetter strings check <pack>: a string pack (data that replaces the wrapped site's own UI strings) against the site's string inventory: only inventory keys, parameter names, plural and select kinds, no markup; prints the coverage. See docs/STRING-PACKS.md.
  • waybetter i18n check|extract: an app's locale catalogs against the framework's rules (file shape, keys English lacks, ICU syntax, placeholder parity, plural forms, no markup), the framework's catalogs in the same languages, the generated languages.json and native localization files (macOS Info.plist and InfoPlist.strings, the Linux .desktop template), bundled plugins' inline English, and the keys the code looks up; configured by the app's waybetter.i18n.json.
  • waybetter/windows/languages/<Language>.nsh: the installer's messages for the 17 languages Tauri does not ship itself, generated from the framework catalogs (wb.installer.*; node packages/waybetter/scripts/installer-languages.mjs --write). An app lists them under bundle.windows.nsis.customLanguageFiles.
  • waybetter/windows/installer.nsi: the Windows installer, a template for Tauri's NSIS bundler (bundle.windows.nsis.template). No wizard and no UAC: it installs for the current user, makes the Desktop and Start menu shortcuts and opens the app. Run again by hand, it only installs when it is newer than what is installed (an upgrade in place, app data untouched); otherwise it opens the installed app and exits 0, so an old setup left in Downloads never replaces or downgrades an install the updater has moved on. /FORCE reinstalls the same version (repair), /NORUN does not open the app, /UPDATE is the updater's path and always installs. An app renamed after its first release keeps its registry keys by defining WAYBETTER_LEGACY_NAME in its installer hooks (bundle.windows.nsis.installerHooks). An install made under the old name (its DisplayName is still that name) counts as older than any setup of the renamed app, whatever its version number: the renamed app may start its numbering again and still upgrades such an install in place.

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.

Dev loop

waybetter dev [--stable] --watch "<build watcher>"... starts the app's watchers, waits for their first build and runs tauri dev (--stable adds --no-watch: a Rust file saved by someone else does not restart the app; restart it yourself after Rust changes). Debug builds only:

  • The app names its page scripts with waybetter_host::page_script!(include_str!("../x.js"), "x.js") in SiteHooks::init_scripts. A debug build reads the file from disk (path relative to the app crate, falling back to the embedded copy) each time a tab's init scripts are built and watches it (devloop, using devmode's debounced signature watcher). When it changes, every tab webview is rebuilt on its current URL through tabs_sync, because a webview's document-start scripts are fixed at creation. Route is kept, scroll is not. About 1 s from save to visible.
  • buildRuntime({ watch: watchRequested(), ... }) (waybetter/build) keeps esbuild rebuilding the file; a failed build leaves the last good one.
  • The shell reloads through the frontend dev server (beforeDevCommand), instantly.
  • Restart needed: Rust, tauri.conf.json, capabilities, bundled plugin sources (include_str! in the plugin table; installed plugin files reload with developer mode as before).
  • Release builds contain none of it: devloop is cfg(debug_assertions), the macro's release arm is $embedded.to_owned() and the path literal is never expanded. release_guard (lib.rs) checks that every mention is gated; cargo test --release also runs release_page_script_is_the_embedded_copy.
  • .taurignore the watched output files (observer.js, x-runtime.js), or plain tauri dev restarts on every rebuild. create-waybetter apps get npm run dev and npm run dev:stable.

Scripts (no CI)

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
node packages/waybetter/scripts/installer-languages.mjs [--write]   # windows/languages/*.nsh from the catalogs
node packages/shell/scripts/google-fonts-catalog.mjs                # the font browser's offline list

dev-tools/ has the harnesses for a running or built app (perf probes, the graceful-close check, tab and webview stress); see its README.

Requires Bun 1.4.2+, Node 24+ and Rust 1.88+.