208 lines
10 KiB
Markdown
208 lines
10 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.
|
|
|
|
## 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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
```
|
|
|
|
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
|
|
bun run test # node --test (runtime + shell) and cargo test (headless host)
|
|
bun run test:ts
|
|
bun run test:rust
|
|
bun run check:tauri # cargo check with the tauri feature (slow: builds Tauri)
|
|
bun run build # typecheck + cargo build
|
|
```
|
|
|
|
Requires Bun 1.4.2+, Node 24+ and Rust 1.88+.
|