// `npm create waybetter -- app`: a whole wayBetter app skeleton for one site, per "One system for // every app" in the waybetter README: a Tauri 2 window (trusted local shell + one child webview // per site tab) on the `waybetter-host` crate, the page runtime and shell models from the // `waybetter` package, and the site adapter in its three parts (native SiteSpec, page SiteAdapter, // capabilities). Selectors are placeholders to fill in from the site's own DOM. import { deflateSync } from 'node:zlib'; import { UsageError, camelCase, headerValue, lines, pascalCase, rustStr, slugify, str } from './names.js'; /** * Known starting points. Everything site-specific is a placeholder until checked against the * live site in devtools: selectors, handle rules, the avatar CDN. */ export const SITE_PRESETS = { 'youtube.com': { label: 'YouTube', hosts: ['www.youtube.com'], apiMarks: ['/youtubei/'], avatarPrefixes: ['https://yt3.ggpht.com/'], handle: { min: 3, max: 30, extra: '._-', sample: 'example_channel' }, selectors: { item: 'ytd-rich-item-renderer, ytd-video-renderer', accountLink: 'ytd-active-account-header-renderer a[href^="/@"]', accountAvatar: '#avatar-btn img', signedOut: 'ytd-button-renderer a[href*="ServiceLogin"]', itemLink: 'a[href^="/watch"], a[href^="/@"], a[href^="/shorts/"]', }, itemPath: '^/(watch|shorts/|@)', accent: '#ff0033', }, 'kick.com': { label: 'Kick', hosts: ['kick.com'], apiMarks: ['/api/'], avatarPrefixes: ['https://files.kick.com/'], handle: { min: 3, max: 25, extra: '_', sample: 'example_user' }, selectors: { item: '[data-chat-entry]', accountLink: 'a[data-testid="user-profile-link"]', accountAvatar: 'button[data-testid="user-menu"] img', signedOut: 'button[data-testid="login"]', itemLink: 'a[href^="/"]', }, itemPath: '^/[A-Za-z0-9_]{3,25}(/|$)', accent: '#53fc18', }, 'twitch.tv': { label: 'Twitch', hosts: ['www.twitch.tv'], apiMarks: ['gql.twitch.tv'], avatarPrefixes: ['https://static-cdn.jtvnw.net/'], handle: { min: 4, max: 25, extra: '_', sample: 'example_user' }, selectors: { item: '[data-a-target="chat-line-message"]', accountLink: '[data-a-target="user-display-name"]', accountAvatar: '[data-a-target="user-menu-toggle"] img', signedOut: '[data-a-target="login-button"]', itemLink: 'a[data-a-target="preview-card-image-link"]', }, itemPath: '^/[A-Za-z0-9_]{4,25}(/|$)', accent: '#9146ff', }, }; const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; /** * @typedef {{ name?: string, site: string, global?: string, identifier?: string, * hostSource?: string, hostPath?: string, gitUrl?: string }} AppInput */ /** @param {AppInput} input */ export function normalizeAppOptions(input) { const raw = String(input.site ?? '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''); if (!raw) throw new UsageError('--site is required (for example youtube.com, kick.com, twitch.tv).'); const presetKey = raw.replace(/^www\./, ''); const preset = SITE_PRESETS[/** @type {keyof typeof SITE_PRESETS} */ (presetKey)]; if (!HOSTNAME.test(raw)) throw new UsageError(`--site "${input.site}" is not a host name like kick.com.`); const label = preset?.label ?? pascalCase(presetKey.split('.')[0]); const site = preset ?? { label, hosts: [raw], apiMarks: ['/api/'], avatarPrefixes: [`https://${raw}/`], handle: { min: 1, max: 30, extra: '_', sample: 'example_user' }, selectors: { item: '[data-example-item]', accountLink: '[data-example-account] a[href^="/"]', accountAvatar: '[data-example-account] img', signedOut: '[data-example-sign-in]', itemLink: 'a[href^="/"]', }, itemPath: '^/[^/]+/?$', accent: '#3b82f6', }; const product = headerValue('App name', input.name || `wayBetter for ${label}`, { max: 60, required: true }); const slug = slugify(product, 48); if (!slug) throw new UsageError(`App name "${product}" needs at least one letter or digit.`); const runtimeGlobal = String(input.global || pascalCase(product)).trim(); if (!/^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/.test(runtimeGlobal) || runtimeGlobal === 'wayBetter') { throw new UsageError(`--global "${runtimeGlobal}" must be a JS identifier (and not "wayBetter", which is the alias every app sets).`); } const identifier = String(input.identifier || `com.example.${slug.replace(/-/g, '')}`).trim(); if (!/^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)+$/.test(identifier)) { throw new UsageError(`--identifier "${identifier}" must be reverse-DNS like com.example.myapp.`); } const hostSource = String(input.hostSource || 'submodule'); if (!['submodule', 'path', 'git'].includes(hostSource)) throw new UsageError('--host-source must be submodule, path or git.'); if (hostSource === 'path' && !input.hostPath) throw new UsageError('--host-source path needs --host-path .'); const key = slugify(label, 24) || 'site'; return { product, slug, crate: slug, lib: `${slug.replace(/-/g, '_')}_lib`, label, site, runtimeGlobal, identifier, hostSource, hostPath: input.hostPath ? String(input.hostPath).replace(/\\/g, '/') : '', gitUrl: String(input.gitUrl || 'https://git.crt.fyi/CRT/waybetter.git'), prefix: `wb-${key}`, dataKey: camelCase(`wb ${key} plugin`), origin: `https://${site.hosts[0]}`, }; } /** @typedef {ReturnType} AppOptions */ /** Where `npm run build` writes what native code and Tauri load (tests check these exist). */ export const APP_OUTPUTS = { runtime: 'src-tauri/runtime/runtime.js', shell: 'dist/index.html' }; /** * The app's own native commands and who may call them (build.rs manifest and capability files). * A new app has no shell command of its own: the host serves the plugins, custom CSS, tweak flags * and settings. */ const COMMANDS = { shell: [], tab: ['tab_report'], site: ['site_ready'], }; /** * The commands waybetter-host serves for every app (`waybetter_host::commands::SHELL` / `TAB` / `SITE`: * tab layout and actions, the shell's focus, first paint and raise, external links, the window * menu, profiles, language, the plugins folder and custom CSS, the page's feature flags and the * shell's settings; tab shortcuts and link drags; opening the app's panel from a site page). The * capability files grant them; a test keeps this list in step. */ export const HOST_COMMANDS = { shell: [ 'tabs_sync', 'tab_navigate', 'tab_action', 'tab_zoom', 'tab_find', 'menu_set', 'safe_mode_get', 'safe_mode_set', 'diag_get', 'diag_log', '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', 'mode_get', 'mode_set', 'settings_get', 'settings_set', 'tab_run_command', ], tab: ['tab_shortcut', 'tab_link_drag'], site: ['open_panel'], }; const permission = command => `allow-${command.replace(/_/g, '-')}`; /** The shell frame's window calls: its titlebar drag region, resize grips and caption buttons. */ const WINDOW_PERMISSIONS = [ 'core:window:allow-start-dragging', 'core:window:allow-start-resize-dragging', 'core:window:allow-internal-toggle-maximize', 'core:window:allow-minimize', 'core:window:allow-toggle-maximize', 'core:window:allow-is-maximized', 'core:window:allow-close', 'core:window:allow-set-background-color', ]; // --------------------------------------------------------------------------------------------- // Icons: a small RGBA PNG (Tauri decodes it for the window icon) and an ICO wrapping it (the // Windows resource). Replace both with real art (`npx tauri icon art.png`). function crc32(bytes) { let crc = ~0; for (const byte of bytes) { crc ^= byte; for (let k = 0; k < 8; k += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return ~crc >>> 0; } function chunk(type, data) { const out = Buffer.alloc(12 + data.length); out.writeUInt32BE(data.length, 0); out.write(type, 4, 'ascii'); data.copy(out, 8); out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); return out; } /** A `size`x`size` RGBA PNG: a rounded square in `color` with a lighter ring. */ export function iconPng(size, color) { const [r, g, b] = [1, 3, 5].map(i => parseInt(color.slice(i, i + 2), 16)); const raw = Buffer.alloc((size * 4 + 1) * size); const radius = size * 0.22; for (let y = 0; y < size; y += 1) { raw[y * (size * 4 + 1)] = 0; for (let x = 0; x < size; x += 1) { const dx = Math.max(radius - x - 0.5, x + 0.5 - (size - radius), 0); const dy = Math.max(radius - y - 0.5, y + 0.5 - (size - radius), 0); const inside = Math.hypot(dx, dy) <= radius; const ring = Math.abs(Math.hypot(x + 0.5 - size / 2, y + 0.5 - size / 2) - size * 0.26) < size * 0.06; const at = y * (size * 4 + 1) + 1 + x * 4; raw[at] = ring ? 255 : r; raw[at + 1] = ring ? 255 : g; raw[at + 2] = ring ? 255 : b; raw[at + 3] = inside ? 255 : 0; } } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // RGBA return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)), ]); } /** An ICO with one PNG image (Windows Vista+ and the `ico` crate read PNG entries). */ export function iconIco(png, size) { const head = Buffer.alloc(22); head.writeUInt16LE(0, 0); head.writeUInt16LE(1, 2); // icon head.writeUInt16LE(1, 4); // one image head[6] = size >= 256 ? 0 : size; head[7] = size >= 256 ? 0 : size; head.writeUInt16LE(1, 10); // planes head.writeUInt16LE(32, 12); // bits per pixel head.writeUInt32LE(png.length, 14); head.writeUInt32LE(22, 18); return Buffer.concat([head, png]); } // --------------------------------------------------------------------------------------------- // Rust /** @param {AppOptions} o */ function hostDependency(o) { if (o.hostSource === 'git') return `waybetter-host = { git = ${rustStr(o.gitUrl)} }`; if (o.hostSource === 'path') { const path = /\/crates\/waybetter-host\/?$/.test(o.hostPath) ? o.hostPath.replace(/\/$/, '') : `${o.hostPath.replace(/\/$/, '')}/crates/waybetter-host`; return `waybetter-host = { path = ${rustStr(path)} }`; } return 'waybetter-host = { path = "../waybetter/crates/waybetter-host" }'; } /** @param {AppOptions} o */ function cargoToml(o) { return lines( '[package]', `name = ${rustStr(o.crate)}`, 'version = "0.1.0"', `description = ${rustStr(`${o.product}: ${o.site.hosts[0]} in a light native window, extended in place`)}`, '# waybetter-host is GPL-3.0-or-later, so an app built on it is too.', 'license = "GPL-3.0-or-later"', 'edition = "2021"', 'rust-version = "1.88"', '', '[lib]', `name = ${rustStr(o.lib)}`, 'path = "src/lib.rs"', '', '[[bin]]', `name = ${rustStr(o.crate)}`, 'path = "src/main.rs"', 'required-features = ["desktop"]', '', '[features]', '# `desktop` is the Tauri app; without it (`--no-default-features`) the site adapter and plugin', '# host build and test headless.', 'default = ["desktop"]', '# `waybetter-host/app`: the window, shell, tab webviews, plugins and generic commands (`run_app`).', 'desktop = ["dep:tauri", "dep:tauri-build", "waybetter-host/app"]', '', '[build-dependencies]', 'tauri-build = { version = "2", features = [], optional = true }', '# The host\'s command list for the app-command manifest (`waybetter_host::commands::manifest`).', hostDependency(o), '', '[dependencies]', '# The wayBetter framework: security policy, profiles, plugin store, tab payloads and, with', '# the desktop feature, the app itself (`waybetter_host::app`).', hostDependency(o), 'tauri = { version = "2", features = ["unstable"], optional = true }', 'serde = { version = "1", features = ["derive"] }', 'serde_json = "1"', 'url = "2"', ); } function buildRs() { const all = [...COMMANDS.shell, ...COMMANDS.tab, ...COMMANDS.site]; return lines( 'fn main() {', ' // The app-command manifest: tauri-build generates an `allow-` permission for each,', ' // which the capability files grant per webview label. `manifest` adds the commands the', ' // wayBetter host serves for every app (`waybetter_host::commands`).', ' #[cfg(feature = "desktop")]', ' tauri_build::try_build(tauri_build::Attributes::new().app_manifest(', ` tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&[${all.map(c => `"${c}"`).join(', ')}])),`, ' ))', ' .expect("Tauri build configuration must be valid");', '}', ); } /** @param {AppOptions} o */ function siteRs(o) { const { handle } = o.site; const extra = [...handle.extra].map(ch => `b'${ch === "'" ? "\\'" : ch}'`); const byteRule = extra.length ? `b.is_ascii_alphanumeric() || ${extra.map(ch => `b == ${ch}`).join(' || ')}` : 'b.is_ascii_alphanumeric()'; return lines( `//! The native half of the ${o.label} site adapter: which webviews are site tabs, the page`, '//! runtime\'s global, and how the account seen inside a profile is validated.', '', 'use waybetter_host::site::{AccountRules, SiteSpec};', '', `/// PLACEHOLDER: a handle ${o.label} could really have (without \`@\`). Check the site's own rules.`, 'pub fn valid_handle(handle: &str) -> bool {', ` (${handle.min}..=${handle.max}).contains(&handle.len()) && handle.bytes().all(|b| ${byteRule})`, '}', '', 'pub static SITE: SiteSpec = SiteSpec {', ` product: ${rustStr(o.product)},`, ' // Exact https origin(s) of site tabs: the only definition of "a site tab".', ` hosts: &[${o.site.hosts.map(rustStr).join(', ')}],`, ' // window. of the page runtime (src/runtime/main.ts); plugins use the', ' // `window.wayBetter` alias.', ` runtime_global: ${rustStr(o.runtimeGlobal)},`, ' accounts: AccountRules {', ' valid_handle,', ` // PLACEHOLDER: the site's own avatar CDN; other avatar URLs are dropped.`, ` avatar_prefixes: &[${o.site.avatarPrefixes.map(rustStr).join(', ')}],`, ' max_name_chars: 50,', ' max_avatar_len: 512,', ' },', '};', '', '#[cfg(test)]', 'mod tests {', ' use super::*;', ' use url::Url;', '', ' #[test]', ' fn only_the_exact_site_origin_is_a_site_tab() {', ` assert!(SITE.is_origin(&Url::parse(${rustStr(`${o.origin}/some/page`)}).unwrap()));`, ` assert!(!SITE.is_origin(&Url::parse(${rustStr(`http://${o.site.hosts[0]}/`)}).unwrap()));`, ` assert!(!SITE.is_origin(&Url::parse(${rustStr(`https://user:pass@${o.site.hosts[0]}/`)}).unwrap()));`, ' assert!(!SITE.is_origin(&Url::parse("https://example.org/").unwrap()));', ' }', '', ' #[test]', ' fn handles_follow_the_site_rules() {', ` assert!(valid_handle(${rustStr(handle.sample)}));`, ' assert!(!valid_handle(""));', ' assert!(!valid_handle("has space"));', ' }', '}', ); } /** @param {AppOptions} o */ function pluginsRs(o) { return lines( '//! BetterDiscord-style plugins for the site page: `*.plugin.js` files in the app-data', '//! `plugins` folder and custom CSS. The plugin store and its commands are the host\'s', '//! (`waybetter_host::app`: `plugins_list`, `plugin_set_enabled`, `custom_css_*`); this is what the', '//! app tells it (`AppConfig::new(&SITE, title, &plugins::HOST)`).', '', 'use waybetter_host::plugins::PluginHost;', '', 'pub static HOST: PluginHost = PluginHost {', ` runtime_global: ${rustStr(o.runtimeGlobal)},`, ' // Plugins run only in the top frame of this exact origin.', ` origin: ${rustStr(o.origin)},`, ` product: ${rustStr(o.product)},`, ` css_element_id: ${rustStr(`${o.prefix}-custom-css`)},`, ' // Written (disabled) into a freshly created plugins folder.', ' example: Some(("example.plugin.js", include_str!("../../plugins/example.plugin.js"))),', ' // First-party plugins shipped with the app: written disabled, upgraded only while untouched.', ' bundled: &[],', ' // Plugin files a later version turned into feature flags (waybetter_host::plugins::migrate).', ' superseded: &[],', '};', '', '#[cfg(test)]', 'mod tests {', ' use super::*;', ' use crate::site::SITE;', '', ' #[test]', ' fn plugins_run_on_the_site_origin_with_its_runtime() {', ' assert_eq!(HOST.origin, SITE.primary_origin());', ' assert_eq!(HOST.runtime_global, SITE.runtime_global);', ' }', '', ' #[test]', ' fn the_example_plugin_has_a_header() {', ' let (file, source) = HOST.example.unwrap();', ' assert!(waybetter_host::plugins::valid_file_name(file));', ' assert_eq!(waybetter_host::plugins::parse_meta(file, source).name, "Example");', ' }', '}', ); } /** @param {AppOptions} o */ function libRs(o) { return lines( `//! ${o.product}: ${o.site.hosts[0]} in one light native window (a trusted local shell and one`, '//! child webview per site tab), extended in place with the wayBetter page runtime and plugins.', '', 'pub mod plugins;', 'pub mod site;', '', '#[cfg(feature = "desktop")]', 'mod desktop;', '', '#[cfg(feature = "desktop")]', 'pub use desktop::run;', ); } /** @param {AppOptions} o */ function mainRs(o) { return lines( '#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]', '', 'fn main() {', ` ${o.lib}::run();`, '}', ); } /** @param {AppOptions} o */ function desktopRs(o) { return lines( '//! The app on the wayBetter host: `waybetter_host::app` makes the window, the trusted local', '//! `shell` webview filling it and one native child webview per tab (placed over the pane', '//! rectangles the shell reports), registers the Tauri plugins and serves the generic commands', '//! (tab layout and actions, shell raise, profiles, language, the plugins folder and custom CSS,', '//! the page\'s feature flags, the shell\'s settings, opening the panel from a site page). This', '//! file adds the site hooks and the app\'s own commands. Every command re-checks its caller: the', '//! capability files are not the boundary.', '', 'use crate::{plugins, site::SITE};', 'use serde::{Deserialize, Serialize};', 'use tauri::{AppHandle, Webview};', 'use url::Url;', 'use waybetter_host::app::{self, App, AppConfig, NewTab, SiteHooks};', 'use waybetter_host::bridge::{self, BridgeOptions};', 'use waybetter_host::host;', 'use waybetter_host::profiles::Account;', 'use waybetter_host::tabs::TabState;', '', '/// The page runtime (src/runtime/main.ts), written by `npm run build`.', 'const RUNTIME: &str = include_str!("../runtime/runtime.js");', '', '// ---------------------------------------------------------------------------------------------', '// Tab commands', '', '#[derive(Deserialize)]', '#[serde(rename_all = "camelCase")]', 'struct TabReport {', ' url: Option,', ' title: Option,', ' account: Option,', ' #[serde(default)]', ' signed_out: bool,', '}', '', '#[derive(Clone, Serialize)]', 'struct TabAccount {', ' id: String,', ' /// None: signed out (or a handle the site could not have).', ' account: Option,', '}', '', '/// Any tab, about itself only: its URL/title for the address bar and, from a tab that is on the', '/// site right now, the account the site\'s own UI shows.', '#[tauri::command]', 'async fn tab_report(webview: Webview, app: AppHandle, report: TabReport) -> Result<(), String> {', ' let id = host::tab_only(&webview)?;', ' if report.url.is_some() || report.title.is_some() {', ' // The URL is the webview\'s own, never the page\'s claim.', ' let url = webview.url().ok().map(|url| url.to_string());', ' host::emit_tab_state(&app, TabState::reported(id.clone(), url, report.title, None, None));', ' }', ' if (report.account.is_some() || report.signed_out) && host::site_tab_only(&SITE, &webview).is_ok() {', ' let account = report.account.and_then(|account| account.validated(&SITE.accounts));', ' host::emit_shell(&app, "tab-account", TabAccount { id, account });', ' }', ' Ok(())', '}', '', '#[derive(Clone, Serialize)]', 'struct SiteReady {', ' id: String,', ' url: String,', '}', '', '/// A site-only command: callable only from a `tab-*` webview that is on the exact site origin', '/// (capability `site-tab`, re-checked here). Add the app\'s own site commands the same way.', '#[tauri::command]', 'async fn site_ready(webview: Webview, app: AppHandle) -> Result<(), String> {', ' let url = host::site_tab_only(&SITE, &webview)?;', ' let id = host::tab_only(&webview)?;', ' host::emit_shell(&app, "site-ready", SiteReady { id, url: url.to_string() });', ' Ok(())', '}', '', '// ---------------------------------------------------------------------------------------------', '// The site on the wayBetter host', '', '/// What only this app knows about its tabs (see `waybetter_host::app::SiteHooks` for the rest:', '/// legacy domains, link routing, per-tab state, ...).', 'struct Site;', '', 'impl SiteHooks for Site {', ' fn init_scripts(&self, app: &AppHandle, _tab: &NewTab) -> Vec {', ' vec![', ' // The framework tab bridge: reports each tab\'s URL and title to the address bar and', ' // forwards its browser shortcuts and link drags to the shell (the shell frame handles both).', ' bridge::script(BridgeOptions::ALL),', ' // The runtime\'s words in the owner\'s language from the first paint (the site only).', ' app::i18n_page_script(app),', ' // The runtime checks the origin itself and stays inert on other sites.', ' // Debug builds read the file from disk and rebuild the tabs when `npm run dev` rebuilds it.', ' waybetter_host::page_script!(RUNTIME, "runtime/runtime.js"),', ' ]', ' }', '', ' /// The page state the host keeps (the feature flags, where links open, custom CSS, the enabled', ' /// plugins), evaluated natively: the page\'s CSP cannot block it.', ' fn site_page_loaded(&self, app: &AppHandle, tab: &Webview, _url: &Url) {', ' let mut scripts = vec![app::i18n_page_script(app)];', ' scripts.extend(app::page_scripts(app).map(app::PageScripts::all).unwrap_or_default());', ' for script in scripts {', ' let _ = tab.eval(&script);', ' }', ' }', '}', '', '// ---------------------------------------------------------------------------------------------', '// App', '', 'pub fn run() {', ' let config = AppConfig {', ' window_size: (1280.0, 820.0),', ' min_window_size: (720.0, 480.0),', ' // The shell frame draws the titlebar (tabs in it, caption buttons on Windows).', ' custom_titlebar: true,', ' // The plugin store (app data `plugins`), the feature flags and the settings are the host\'s.', ' ..AppConfig::new(&SITE, SITE.product, &plugins::HOST)', ' };', ` App::new(config, Site).run(tauri::generate_context!(), tauri::generate_handler![${[...COMMANDS.shell, ...COMMANDS.tab, ...COMMANDS.site].join(', ')}]);`, '}', ); } /** @param {AppOptions} o */ function securityRs(o) { const quote = list => list.map(c => `"${c}"`).join(', '); return lines( `//! Who may call which command, read from the capability files, and whether each command checks`, '//! its caller first (waybetter-host `testkit`). Runs with `npm run test:rust`.', '', `use ${o.lib}::site::SITE;`, 'use waybetter_host::testkit::{assert_guarded, assert_host_guards, Capabilities};', '', 'fn capabilities() -> Capabilities {', ' Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"))', '}', '', '#[test]', 'fn host_commands_keep_their_callers() {', ' let caps = capabilities();', ' caps.assert_host_commands();', ' assert_host_guards();', ' // The host\'s site commands (opening the panel from a page) are for tabs on the site only.', ' for command in waybetter_host::commands::SITE {', ' caps.assert_site_only(command, &SITE);', ' }', '}', '', '#[test]', 'fn app_commands_keep_their_callers() {', ' let caps = capabilities();', ' let source = include_str!("../src/desktop.rs");', ...(COMMANDS.shell.length ? [ ` for command in [${quote(COMMANDS.shell)}] {`, ' caps.assert_shell_only(command);', ' assert_guarded(source, command, "host::shell_only(&webview)?");', ' }', ] : []), ` for command in [${quote(COMMANDS.tab)}] {`, ' caps.assert_tab_only(command);', ' }', ` for command in [${quote(COMMANDS.site)}] {`, ' caps.assert_site_only(command, &SITE);', ' }', ' assert_guarded(source, "tab_report", "host::tab_only(&webview)?");', ' assert_guarded(source, "site_ready", "host::site_tab_only(&SITE, &webview)?");', ' // Only Rust drives the Tauri plugins; no capability exposes their JS APIs.', ' caps.assert_no_plugin_apis(&["updater", "process", "notification", "deep-link", "single-instance", "window-state", "opener", "dialog"]);', '}', ); } /** The framework's NSIS template, from the app's `src-tauri` folder (tauri.conf.json paths). */ const WINDOWS_INSTALLER = '../node_modules/waybetter/windows/installer.nsi'; /** @param {AppOptions} o */ function tauriConf(o) { return { $schema: 'https://schema.tauri.app/config/2', productName: o.product, version: '0.1.0', identifier: o.identifier, build: { beforeDevCommand: 'npm run build', beforeBuildCommand: 'npm run build', frontendDist: '../dist', }, app: { withGlobalTauri: true, // The window is made by waybetter-host (`app::run_app`: a multiwebview window, shell + tab webviews). windows: [], security: { capabilities: ['shell', 'tab-chrome', 'site-tab'], csp: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; frame-src 'none'; form-action 'none'", }, }, bundle: { active: true, targets: 'all', icon: ['icons/icon.png', 'icons/icon.ico'], // Windows: the framework's installer (no wizard, per user; a setup run again opens the // installed app instead of replacing or downgrading it). See waybetter/windows/installer.nsi. windows: { nsis: { installMode: 'currentUser', template: WINDOWS_INSTALLER } }, }, }; } /** @param {AppOptions} o */ function capabilities(o) { const cap = value => `${JSON.stringify({ $schema: '../gen/schemas/desktop-schema.json', ...value }, null, 2)}\n`; return [ { path: 'src-tauri/capabilities/shell.json', content: cap({ identifier: 'shell', description: 'The trusted local shell: the wayBetter host\'s commands (tab layout and actions, profiles, language, plugins and custom CSS, tweaks, settings). No generic filesystem or shell APIs.', webviews: [/** policy::SHELL_LABEL */ 'shell'], local: true, permissions: ['core:event:default', ...WINDOW_PERMISSIONS, ...HOST_COMMANDS.shell.map(permission), ...COMMANDS.shell.map(permission)], }), }, { path: 'src-tauri/capabilities/tab-chrome.json', content: cap({ identifier: 'tab-chrome', description: 'Every tab (any site): report its own URL, title and, on the site, its account; forward its shortcuts and link drags. Nothing else.', webviews: ['tab-*'], local: false, remote: { urls: ['https://*/*', 'http://*/*'] }, permissions: [...COMMANDS.tab.map(permission), ...HOST_COMMANDS.tab.map(permission)], }), }, { path: 'src-tauri/capabilities/site-tab.json', content: cap({ identifier: 'site-tab', description: `Tabs on ${o.site.hosts.join(', ')} only: the app's short, reviewed list of site commands, and the host's (opening the app's panel).`, webviews: ['tab-*'], local: false, remote: { urls: o.site.hosts.map(host => `https://${host}/*`) }, permissions: [...COMMANDS.site.map(permission), ...HOST_COMMANDS.site.map(permission)], }), }, ]; } /** @param {AppOptions} o */ function releaseConfig(o) { return { product: o.product, artifact: o.product.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || o.slug, desktopDir: '.', // PLACEHOLDERS: where the signing key lives (never in the repository) and where releases go. keysDir: `~/.${o.slug}-keys`, keysDirEnv: 'WAYBETTER_KEYS_DIR', cdnBase: `https://downloads.example.com/${o.slug}`, apiBase: 'https://updates.example.com', outDir: 'releases', }; } // --------------------------------------------------------------------------------------------- // TypeScript: site adapter, page runtime, shell /** @param {AppOptions} o */ function siteTs(o) { const s = o.site.selectors; return lines( `// The page half of the ${o.label} site adapter (waybetter/runtime/site): every runtime module`, '// takes only its piece. Must agree with the native `SiteSpec` in src-tauri/src/site.rs.', '', "import type { SiteAdapter } from 'waybetter/runtime/site';", "import type { SeenAccount } from 'waybetter/runtime/accountReport';", '', '/**', ` * PLACEHOLDER selectors: starting guesses for ${o.site.hosts[0]}. Open the site in devtools and`, ' * replace each one; sites change their markup, so prefer stable attributes (data-*, aria-*).', ' */', 'export const SELECTORS = {', ' /** One item plugins act on (a post, a message, a video card). */', ` item: ${str(s.item)},`, ' /** A link to the signed-in account (its href holds the handle). */', ` accountLink: ${str(s.accountLink)},`, ' /** The signed-in account\'s avatar image. */', ` accountAvatar: ${str(s.accountAvatar)},`, ' /** Only present on the signed-out page. */', ` signedOut: ${str(s.signedOut)},`, ' /** Links to the site\'s own pages that the "links" setting may open beside the pane. */', ` itemLink: ${str(s.itemLink)},`, '} as const;', '', `/** PLACEHOLDER: paths of the site's own pages a click may divert (see links.clickedUrl). */`, `const ITEM_PATH = new RegExp(${str(o.site.itemPath)});`, '', 'function readAccount(root: ParentNode): SeenAccount | null {', ' const link = root.querySelector(SELECTORS.accountLink);', ' if (!link) return null;', " const handle = (link.getAttribute('href') ?? '').replace(/^\\/+@?/, '').split(/[/?#]/)[0] ?? '';", ' if (!handle) return null;', ' const avatar = root.querySelector(SELECTORS.accountAvatar)?.src ?? null;', " return { handle, name: link.textContent?.trim() || handle, avatar };", '}', '', 'function clickedUrl(target: Element | null, location: Location): string | null {', ' const anchor = target?.closest(SELECTORS.itemLink);', ' if (!anchor?.href) return null;', ' const url = new URL(anchor.href, location.href);', ' return url.origin === location.origin && ITEM_PATH.test(url.pathname) && url.href !== location.href ? url.href : null;', '}', '', 'export const SITE: SiteAdapter = {', ` product: ${str(o.product)},`, ` hosts: [${o.site.hosts.map(str).join(', ')}],`, ` runtimeGlobal: ${str(o.runtimeGlobal)},`, ' heavyPath: null,', ` apiMarks: [${o.site.apiMarks.map(str).join(', ')}],`, ' xhrMarks: [],', ' plugins: {', ` name: ${str(o.runtimeGlobal)},`, ` product: ${str(o.product)},`, ` storagePrefix: ${str(`${o.prefix}-plugin:`)},`, ` styleDataKey: ${str(o.dataKey)},`, ` toastId: ${str(`${o.prefix}-ui`)},`, ` toastBackground: ${str(o.site.accent)},`, ' itemSelector: SELECTORS.item,', " // Keys that start one of the site's own two-key shortcuts.", ' chordKeys: [],', ' },', ' account: { read: readAccount, signedOut: root => !!root.querySelector(SELECTORS.signedOut) },', " links: { configKey: 'site', clickedUrl },", '};', ); } /** @param {AppOptions} o */ function addressTs(o) { return lines( '// Address bar chips (waybetter/shell/addressModel): how the shell names this site\'s pages.', '', "import type { AddressSite, Segment } from 'waybetter/shell/addressModel';", "import { SITE } from './site.ts';", '', 'function label(part: string): string {', ' try { return decodeURIComponent(part); } catch { return part; }', '}', '', 'export const ADDRESS_SITE: AddressSite = {', " kind: 'site',", ' hosts: new Set(SITE.hosts),', ` label: ${str(o.label)},`, ' home: origin => `${origin}/`,', ' // PLACEHOLDER: one chip per path part; name the site\'s own pages (a channel, a video) here.', ' segments(url): Segment[] {', " const parts = url.pathname.split('/').filter(Boolean).slice(0, 3);", " return parts.map((part, index) => ({ label: label(part), href: `${url.origin}/${parts.slice(0, index + 1).join('/')}` }));", ' },', '};', ); } function runtimeTs(o) { return lines( '// The page runtime, injected into every tab and active only on the site\'s origin (top frame).', '// It is composed with the framework\'s helpers (waybetter/runtime/siteRuntime): the runtime', '// object under `window.` (and the `window.wayBetter` name plugins use) with the', '// plugin API and what native code delivers (the feature flags, the link setting, the language);', '// native code then evaluates the enabled `*.plugin.js` files against it.', '', "import { installAccountReport } from 'waybetter/runtime/accountReport';", "import { installLinkRouting } from 'waybetter/runtime/linkRouting';", "import { createPageStrings } from 'waybetter/runtime/pageStrings';", "import { installPanelButton } from 'waybetter/runtime/panelButton';", "import { createPluginContext } from 'waybetter/runtime/permissions';", "import { createFeatureRegistry, type FeatureRegistry } from 'waybetter/runtime/featureModules';", "import { effectiveFlags, tableDefaults, type Flags } from 'waybetter/runtime/featureTable';", "import { createCorePluginApi, type CorePluginApi } from 'waybetter/runtime/pluginApi';", "import { configureRuntime, isSiteUrl } from 'waybetter/runtime/site';", "import { createFeatureFlags, installSiteRuntime, pageInvoke, type FlagInstaller } from 'waybetter/runtime/siteRuntime';", "import { KIT_MESSAGES } from 'waybetter/runtime/ui';", "import { TABLE } from '../features.ts';", "import { SITE } from '../site.ts';", "import { MODULES } from './modules.ts';", '', '/**', ' * The flags are the feature table\'s (src/features.ts): the owner toggles them on the panel\'s', ' * area cards and native `features_set` delivers them. A feature module (src/runtime/modules.ts)', ' * is started by the registry; a part of the runtime that must install on a flag at document', ' * start (a request gate) goes into `INSTALLERS` with `backing: \'app\'` in the table.', ' */', 'const INSTALLERS: FlagInstaller[] = [];', '', '/** The app\'s logo for the floating panel button (a data URI: the site\'s CSP may block other images). */', `const LOGO = ${str(`data:image/svg+xml,${encodeURIComponent(``)}`)};`, '', 'function install(): void {', ' if (window.top !== window || !isSiteUrl(SITE, location.href)) return;', ' if ((window as unknown as Record)[SITE.runtimeGlobal]) return;', ' configureRuntime(SITE);', ' const invoke = pageInvoke(window, SITE.product);', ' let registry: FeatureRegistry | null = null;', ' const flags = createFeatureFlags({', ' defaults: tableDefaults(TABLE), label: SITE.product, installers: INSTALLERS, document,', ' // An area that is switched off turns off the features it owns.', ' resolve: all => effectiveFlags(TABLE, all),', ' apply: config => registry?.setFlags(config),', ' });', ' // English for the plugin UI kit\'s own words; native code delivers the owner\'s language (i18n_set).', " const strings = createPageStrings({ english: { ...KIT_MESSAGES }, prefixes: ['kit.', 'plugin.'], label: SITE.product });", ' // No theme engine yet: `api.theme` reports the page\'s own colors. Pass `{ theme: source }`', ' // (a ThemeSource from waybetter/runtime/themeApi) once the app has themes.', ' const api = createCorePluginApi(window, SITE.plugins, { i18n: strings.source });', ' // The feature modules: each runs only while its flag and its area are on. `surfaces` names the', ' // parts of the site the page is (PLACEHOLDER: classify location.pathname; a feature that lists', ' // surfaces in the table only runs on those).', " registry = createFeatureRegistry({ win: window, table: TABLE, api, label: SITE.product, surfaces: () => ['page'], message: strings.message, locale: strings.locale });", ' for (const module of MODULES) registry.define(module);', ' registry.setFlags(flags.current());', ' // Each plugin gets an API view for its declared @permissions, with its own strings.', ' const plugins = createPluginContext(api, undefined, (full, id) => ({ i18n: full.i18n.namespace(strings.pluginNamespace(id)) }));', ' const links = SITE.links ? installLinkRouting(window, SITE.links) : undefined;', ' if (SITE.account) installAccountReport(window, SITE.account, invoke);', ' const runtime = installSiteRuntime({', ' win: window, site: SITE, version: \'0.1.0\', invoke, plugins, strings,', ' pluginFailed: error => `Plugin failed: ${error}`,', ' features: flags,', ' // "same" until native code configures it: in-page clicks are left alone.', ' links,', ' });', ' // The way into the app\'s panel from the page: a small floating button (add `mountNav` to put', ' // an entry into the site\'s own navigation instead).', ` installPanelButton(window, { id: ${str(`${o.prefix}-panel-button`)}, logo: LOGO, title: () => SITE.product }, () => runtime.open());`, " invoke?.('site_ready', {}).catch(() => {});", '}', '', 'try {', ' install();', '} catch (error) {', ' console.error(`[${SITE.product}] runtime failed`, error);', '}', ); } /** @param {AppOptions} o */ function shellTs(o) { const { handle } = o.site; const handleChars = handle.extra.replace(/[\\\]^-]/g, ch => `\\${ch}`); return lines( '// The shell: the wayBetter shell frame (waybetter/shell/frame) with this site\'s pages and the', '// app\'s panel (Panel.tsx: waybetter/shell/panel). Tabs in the titlebar, splits, the address bar,', '// the new-tab page, the command palette (Ctrl+K) and the account switcher are the framework\'s;', '// native code places each tab\'s webview over its pane (tabs_sync).', '', "import { createRoot } from 'react-dom/client';", "import { createI18n } from 'waybetter/i18n';", "import en from 'waybetter/locales/en.json' with { type: 'json' };", "import { isSiteUrl } from 'waybetter/runtime/site';", "import { parseAddress } from 'waybetter/shell/addressModel';", "import { createShell, type ShellSite } from 'waybetter/shell/frame';", "import { openOnboardingWhenDue } from 'waybetter/shell/panel';", "import { ADDRESS_SITE } from '../address.ts';", "import { MESSAGES } from '../features.ts';", "import { SITE } from '../site.ts';", "import { Panel } from './Panel.tsx';", "import './app.css';", '', `const ORIGIN = ${str(o.origin)};`, '', '/** English; merge `waybetter/locales/.json` (and your own catalog) for other languages. */', "const i18n = createI18n({ source: { ...en.messages, ...MESSAGES }, available: ['en'] });", '', 'const site: ShellSite = {', ` name: ${str(o.label)},`, ' product: SITE.product,', " logo: './icon.png',", ' homeUrl: `${ORIGIN}/`,', " // PLACEHOLDER: the site's own sign-in and sign-out pages.", ' loginUrl: `${ORIGIN}/login`,', ' logoutUrl: `${ORIGIN}/logout`,', ' isSiteUrl: url => isSiteUrl(SITE, url),', ' // Same rules as src-tauri/src/site.rs (valid_handle, avatar_prefixes).', ` accounts: { handle: /^[A-Za-z0-9${handleChars}]{${handle.min},${handle.max}}$/, avatarPrefixes: [${o.site.avatarPrefixes.map(str).join(', ')}], handlePrefix: '@' },`, " // PLACEHOLDER: the site's search page.", ' searchUrl: text => `${ORIGIN}/search?q=${encodeURIComponent(text)}`,', ' address: url => parseAddress(url, ADDRESS_SITE),', " panel: { id: 'app', title: SITE.product, component: Panel },", ' newTab: {', ` search: () => ${str(`Search ${o.label}`)},`, " links: [{ id: 'home', label: () => 'Home', icon: 'home', url: home => home }],", ' },', ' palette: {', " placeholder: () => 'Search tabs, pages and settings',", ' // The panel\'s sections in Ctrl+K.', ' sections: [', " { id: 'plugins', label: () => 'Plugins', keywords: 'extensions js custom css', icon: 'grid' },", " { id: 'accounts', label: () => 'Accounts', keywords: 'profiles logins', icon: 'user' },", " { id: 'settings', label: () => 'Settings', keywords: 'home page links session', icon: 'settings' },", ' ],', ' },', ' // Native features this app does not wire up yet (waybetter-host workspaces, sync, devmode,', ' // plugins::commands) and panel sections it does not have (the Store, the updater): the frame', ' // shows nothing of them until they are switched on here. Page zoom (`tab_zoom`) is the host\'s own.', ' features: { workspaces: false, sync: false, developer: false, pluginCommands: false, store: false, updates: false },', ' // The app menu (the titlebar\'s "Settings and more", Alt+F / F10) needs nothing: without `menu`', ' // the app gets every built-in row it can serve (new tab, splits, zoom, Plugins, the palette,', ' // About, Settings). `menu.groups` orders them and adds rows of your own, e.g.', ' // menu: { groups: menuWith(DEFAULT_MENU, "plugins", [{ id: "help", label: () => "Help", run: () => openHelp() }]) },', ' // and `menu.languages` / `menu.modes` (menuModes() from waybetter/shell/panel) add those submenus.', ' // The first-run screen (the modes of src/features.ts) is the panel\'s: open it when it is due.', ' start() { openOnboardingWhenDue(); return undefined; },', '};', '', 'const { core, event, window: tauriWindow } = window.__TAURI__;', 'const Shell = createShell(site, { native: { invoke: core.invoke, listen: event.listen, currentWindow: tauriWindow.getCurrentWindow }, i18n });', "createRoot(document.getElementById('root')!).render();", ); } /** @param {AppOptions} o */ function featuresTs(o) { return lines( '// The feature table: everything the owner can switch, as data (waybetter/runtime/featureTable).', '// An AREA is one card in the panel\'s Plugins section with a switch of its own; a FEATURE is one', '// flag, owned by an area or by nobody (a core flag: listed under "Fixes"); a MODE is a preset the', '// first-run screen offers. The page runtime (src/runtime/main.ts) and the panel', '// (src/shell/Panel.tsx) both read this table, so they cannot disagree. A flag\'s id never changes', '// once it shipped: it is in the owner\'s features.json and backups.', '', "import type { FeatureTable } from 'waybetter/runtime/featureTable';", '', '/** PLACEHOLDER: one example area with one feature; its code is in src/runtime/modules.ts. */', 'export const TABLE: FeatureTable = {', ' areas: [', ` { id: 'reading', name: 'Reading+', description: 'area.reading.description', owner: ${str(`${o.prefix}-reading-plus.plugin.js`)}, namespace: 'reading-plus' },`, ' ],', ' features: [', " // `backing: 'module'`: a feature module the registry starts while the flag is on. Other kinds:", " // 'app' (a part of the runtime that installs on the flag) and 'plugin' (a plugin file).", " { id: 'reading.focusRing', area: 'reading', default: false, backing: 'module', label: 'feature.focusRing.label', description: 'feature.focusRing.description' },", ' ],', ' modes: [', " { id: 'basic', name: 'mode.basic.name', description: 'mode.basic.description', flags: { 'area.reading': false } },", " { id: 'advanced', name: 'mode.advanced.name', description: 'mode.advanced.description', recommended: true, flags: {} },", " { id: 'power', name: 'mode.power.name', description: 'mode.power.description', flags: { 'reading.focusRing': true } },", ' ],', '};', '', '/** The table\'s words in English (merged into the shell\'s catalog in src/shell/main.tsx). */', 'export const MESSAGES: Record = {', " 'area.reading.description': 'Reading the page: small comforts.',", " 'feature.focusRing.label': 'Mark the focused item',", " 'feature.focusRing.description': 'Draws a ring around the item that has keyboard focus.',", " 'mode.basic.name': 'Basic',", ` 'mode.basic.description': ${str(`Only the app around ${o.label}. The page looks the way the site ships it.`)},`, " 'mode.advanced.name': 'Advanced',", " 'mode.advanced.description': 'Every plugin on with calm defaults.',", " 'mode.power.name': 'Power User',", " 'mode.power.description': 'Advanced, plus the keyboard helpers.',", '};', ); } function modulesTs() { return lines( '// Feature modules: the code of the table\'s features (src/features.ts). The registry starts a', '// module only while its flag is on, its area is on and the page is on one of its surfaces, and', '// undoes everything the module did through its context when any of those stops being true.', '', "import type { FeatureModule } from 'waybetter/runtime/featureModules';", "import type { CorePluginApi } from 'waybetter/runtime/pluginApi';", "import { SITE } from '../site.ts';", '', 'export const MODULES: ReadonlyArray> = [', ' {', " id: 'reading.focusRing',", ' start(ctx) {', ' // One stylesheet while the feature is on; removed when it stops.', ' ctx.css(`${SITE.plugins.itemSelector}:focus-within { outline: 2px solid Highlight; outline-offset: 2px; }`);', ' },', ' },', '];', ); } function panelTsx(o) { return lines( '// The app\'s own tab (Ctrl+Shift+W, the brand button, the floating button in the site\'s page,', '// Ctrl+K "Plugins"): the wayBetter panel (waybetter/shell/panel). The framework brings the', '// sections this app serves: Home, Plugins (the area cards of src/features.ts, plugin files and', '// custom CSS), Accounts and Settings. It grows', '// with the app: `themes` adds Appearance and the Theme Builder, `store` the Store, `make` the Make', '// section, `backup` and `updates` theirs, `sections` the app\'s own (see `PanelSite`).', '', "import { createPanel } from 'waybetter/shell/panel';", "import { TABLE } from '../features.ts';", "import { SITE } from '../site.ts';", '', 'export const Panel = createPanel({', " brand: { name: SITE.product, logo: './icon.png' },", ' home: {', ` kicker: { name: 'wayBetter', rest: ${str(` for ${o.label}`)} },`, ` title: () => [${str(`${o.label}, `)}, 'extended', '.'],`, ` lede: () => ${str(`${o.site.hosts[0]} in a light native window: tabs, split panes, accounts side by side and plugins. The page is the site's own; this app extends it in place.`)},`, " actions: [{ section: 'plugins', label: () => 'Plugins' }, { section: 'settings', label: () => 'Settings' }],", ' highlights: [', " { section: 'plugins', title: () => 'Plugins', text: () => 'One file each, switched on and off live.' },", " { section: 'accounts', title: () => 'Accounts', text: () => 'Real logins side by side, one per profile.' },", ' ],', ' },', ' // Area plugins, the Fixes list and the modes (first-run screen, Settings): src/features.ts.', ' features: { table: TABLE },', ' // Placeholder CSS in the custom CSS editor, written with this site\'s own selectors.', ` cssExamples: { custom: ${str(`/* Example */\n${o.site.selectors.item} { outline: 1px dashed; }`)} },`, '});', ); } function tauriTypes() { return lines( '// `window.__TAURI__` in the shell (tauri.conf.json: app.withGlobalTauri): what the shell frame and', '// the panel call. Swap for `@tauri-apps/api` if you prefer imports.', '', 'type TauriWindow = {', ' minimize(): Promise;', ' toggleMaximize(): Promise;', ' close(): Promise;', " startResizeDragging(direction: 'North' | 'South' | 'East' | 'West' | 'NorthEast' | 'NorthWest' | 'SouthEast' | 'SouthWest'): Promise;", ' startDragging(): Promise;', ' isMaximized(): Promise;', ' onResized(handler: () => void): Promise<() => void>;', ' setBackgroundColor(color: [number, number, number, number]): Promise;', '};', '', 'interface Window {', ' __TAURI__: {', ' core: { invoke(command: string, args?: Record): Promise };', ' event: { listen(event: string, handler: (event: { payload: T }) => void): Promise<() => void> };', ' window: { getCurrentWindow(): TauriWindow };', ' };', '}', ); } /** @param {AppOptions} o */ function indexHtml(o) { return lines( '', '', '', ' ', ' ', ' ', ` ${o.product.replace(/[<&]/g, ch => (ch === '<' ? '<' : '&'))}`, ' ', ' ', ' ', ' ', '', '', '
', '', '', ); } /** @param {AppOptions} o */ function appCss(o) { return lines( "/* The app's own styles. The shell frame's and the panel's styles come with them (dockview, the", " frame's and the panel's CSS, bundled into dist/shell.css); their colors are the --wb-c-* tokens", ' (Night Ink by default). */', `:root { --app-accent: ${o.site.accent}; }`, ); } /** @param {AppOptions} o */ function examplePlugin(o) { return lines( '/**', ' * @name Example', ` * @description Example plugin for ${o.product}: outlines each item and says hello. Turn it on in Plugins.`, ' * @version 1.0.0', ` * @author ${o.product}`, ' */', 'module.exports = class Example {', ' start() {', ' const api = window.wayBetter.api;', ' this.scope = api.scope();', ` this.scope.css('example', ${str(`${o.site.selectors.item} { outline: 1px dashed var(--wb-accent); }`)});`, " this.scope.ui.toast('Example plugin started', { tone: 'success' });", ' }', '', ' stop() {', ' this.scope.dispose();', ' }', '};', ); } function buildMjs() { return lines( '// Builds the page runtime (one IIFE that native code injects into tabs) and the shell (the local', '// page Tauri serves from dist/: the wayBetter shell frame, bundled with its styles), both with', "// `waybetter/build`, and writes the shell's first-frame script.", "import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';", "import { buildRuntime, watchRequested } from 'waybetter/build';", "import { bootScript } from 'waybetter/shell/frame/boot';", '', "mkdirSync('dist', { recursive: true });", "// `node scripts/build.mjs --watch` (npm run dev) keeps rebuilding the runtime; a debug app reloads its tabs.", `await buildRuntime({ entry: 'src/runtime/main.ts', outfile: ${str(APP_OUTPUTS.runtime)}, watch: watchRequested() });`, 'await buildRuntime({', " entry: 'src/shell/main.tsx',", " outfile: 'dist/shell.js',", " format: 'esm',", " jsx: 'automatic',", ' minify: false,', ' // CSS imports (dockview, the frame, app.css) are bundled into dist/shell.css.', ' inlineCss: false,', " // Developer mode's syntax check loads acorn; this app does not switch developer mode on.", " external: ['acorn'],", '});', "writeFileSync('dist/boot.js', bootScript());", "copyFileSync('src/shell/index.html', 'dist/index.html');", "copyFileSync('src-tauri/icons/icon.png', 'dist/icon.png');", "console.log(watchRequested() ? 'watching " + APP_OUTPUTS.runtime + "' : 'built dist/ and " + APP_OUTPUTS.runtime + "');", ); } /** @param {AppOptions} o */ function readme(o) { const submodule = o.hostSource === 'submodule'; return lines( `# ${o.product}`, '', `${o.site.hosts[0]} in one light native window, extended in place: a wayBetter app made with`, '`npm create waybetter`. It is the same system as every wayBetter app: a Tauri 2 window with a', 'trusted local **shell** and one native child webview per **tab**, the **page runtime** injected', 'into tabs on the site, and a small **site adapter**.', '', '## Run it', '', '```sh', ...(submodule ? [`git init && git submodule add ${o.gitUrl} waybetter # the framework (host crate)`] : []), 'npm install', 'npm run build # dist/ (shell) and ' + APP_OUTPUTS.runtime + ' (page runtime)', 'npm run tauri dev # the app', 'npm run dev # the app with the dev loop: a saved runtime change reloads the tabs (debug build)', 'npm run dev:stable # the same, and Rust changes do not restart the app', 'npm run test:rust # site adapter + plugin host tests, headless', '```', '', 'Requires Node 18+, Rust 1.88+ and the Tauri 2 prerequisites for your platform.', '', '## Layout', '', '```', 'src/site.ts SiteAdapter: the page half of the adapter (PLACEHOLDER selectors)', 'src/address.ts address bar chips for the site\'s pages', 'src/features.ts the feature table: areas (plugin cards), feature flags, modes (first-run presets)', 'src/runtime/main.ts page runtime: window.' + o.runtimeGlobal + ' (+ window.wayBetter), plugin API, feature flags and modules, account report, the panel button', 'src/runtime/modules.ts feature modules: the code of the table\'s features', 'src/shell/main.tsx the shell: the wayBetter shell frame (createShell) with this site\'s pages', 'src/shell/Panel.tsx the app\'s own tab: the wayBetter panel (createPanel) with this app\'s home page', 'plugins/example.plugin.js written (disabled) into a new plugins folder', 'src-tauri/src/site.rs SiteSpec: the native half (hosts, runtime global, account rules)', 'src-tauri/src/plugins.rs PluginHost: what the host\'s plugin store needs to know about this app', 'src-tauri/src/desktop.rs config, site hooks and the app\'s own commands (waybetter_host::app runs the rest)', 'src-tauri/capabilities/ shell / tab-chrome / site-tab, keyed by webview label', 'src-tauri/tests/security.rs who may call what (waybetter_host::testkit)', 'waybetter.release.json `npm run release`: signed builds and the update manifest (PLACEHOLDER URLs)', '```', '', 'Build with `npm run desktop:build` (or `npm run release`), not a bare `tauri build`: a Rust binary', 'embeds the paths it was built from, and those hold your account name. `waybetter build-env` maps', 'them to `/home`, `/src`, `/cargo` and `/rustup`; `npm run privacy:scan` and the release check the', 'result (`waybetter privacy-scan --help`).', '', '```text', '```', '', '## Make it yours', '', `1. Open ${o.origin} in a browser's devtools and replace every PLACEHOLDER in \`src/site.ts\``, ' and `src-tauri/src/site.rs` (selectors, handle rules, the avatar CDN).', '2. Keep `hosts` and the runtime global identical in `site.ts` and `site.rs`.', '3. Add site-only commands like `site_ready`: `host::site_tab_only(&SITE, &webview)?` first,', ' then list them in `build.rs`, `capabilities/site-tab.json` and `tests/security.rs`.', '4. Features: add a row to `TABLE` in `src/features.ts` (an area is a card in Plugins; a feature', ' a toggle on it; `area: null` lists it under Fixes) and its module to `src/runtime/modules.ts`.', ' The modes of the first-run screen are `TABLE.modes`.', '5. Next steps from the framework: account profiles (`waybetter_host::profiles`), split panes', ' (`waybetter/shell/paneModel`), the rail hider (`waybetter/runtime/paneNav`), themes', ' (`waybetter/runtime/themeApi`, `theme.schema.json`), signed updates (`updater: true` in the', ' `AppConfig`, `plugins.updater` and `bundle.createUpdaterArtifacts` in tauri.conf.json, then', ' `waybetter.release.json` and `npm run release`).', '', '## Security model', '', '- Only the local `shell` webview may call shell commands (`host::shell_only`), the host\'s too.', '- Any tab may only report about itself (`tab_report`); the URL used is the webview\'s own.', '- Tabs on the exact site origin may call the short list in `site-tab.json`, re-checked natively.', '- Plugins are files the owner copies into the plugins folder; native code evaluates them only', ' in the site\'s top frame.', '', 'License: GPL-3.0-or-later (waybetter-host is GPL-3.0-or-later).', ); } /** @param {AppOptions} o */ export function appFiles(o) { const png = iconPng(64, o.site.accent); const json = value => `${JSON.stringify(value, null, 2)}\n`; const pkg = { name: o.slug, version: '0.1.0', private: true, type: 'module', license: 'GPL-3.0-or-later', description: `${o.product}: ${o.site.hosts[0]} in a light native window, extended in place.`, scripts: { build: 'node scripts/build.mjs', typecheck: 'tsc -p tsconfig.json', tauri: 'tauri', dev: 'waybetter dev --watch "node scripts/build.mjs --watch"', 'dev:stable': 'waybetter dev --stable --watch "node scripts/build.mjs --watch"', 'desktop:build': 'waybetter build-env -- tauri build', 'privacy:scan': 'waybetter privacy-scan --tracked .', 'test:rust': 'cargo test --manifest-path src-tauri/Cargo.toml --no-default-features', release: 'waybetter release windows', 'release:macos': 'waybetter release macos', }, dependencies: { dockview: '^4.0.0', react: '^19.0.0', 'react-dom': '^19.0.0', waybetter: '^0.7.0' }, devDependencies: { '@tauri-apps/cli': '^2.0.0', '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0', esbuild: '^0.25.0', typescript: '^5.9.0' }, }; const tsconfig = { compilerOptions: { target: 'ES2022', lib: ['ES2022', 'DOM', 'DOM.Iterable'], module: 'ESNext', moduleResolution: 'Bundler', allowImportingTsExtensions: true, resolveJsonModule: true, jsx: 'react-jsx', noEmit: true, strict: true, noUnusedLocals: true, skipLibCheck: true, isolatedModules: true, types: [], }, include: ['src'], }; return [ { path: 'README.md', content: readme(o) }, { path: 'package.json', content: json(pkg) }, { path: 'tsconfig.json', content: json(tsconfig) }, { path: '.gitignore', content: lines('node_modules/', 'dist/', 'releases/', 'src-tauri/target/', 'src-tauri/gen/', APP_OUTPUTS.runtime) }, { path: 'waybetter.release.json', content: json(releaseConfig(o)) }, { path: 'scripts/build.mjs', content: buildMjs() }, { path: 'src/site.ts', content: siteTs(o) }, { path: 'src/address.ts', content: addressTs(o) }, { path: 'src/features.ts', content: featuresTs(o) }, { path: 'src/runtime/main.ts', content: runtimeTs(o) }, { path: 'src/runtime/modules.ts', content: modulesTs() }, { path: 'src/shell/main.tsx', content: shellTs(o) }, { path: 'src/shell/Panel.tsx', content: panelTsx(o) }, { path: 'src/shell/index.html', content: indexHtml(o) }, { path: 'src/shell/app.css', content: appCss(o) }, { path: 'src/tauri.d.ts', content: tauriTypes() }, { path: 'plugins/example.plugin.js', content: examplePlugin(o) }, { path: 'src-tauri/Cargo.toml', content: cargoToml(o) }, { path: 'src-tauri/build.rs', content: buildRs() }, { path: 'src-tauri/tauri.conf.json', content: json(tauriConf(o)) }, ...capabilities(o), { path: 'src-tauri/icons/icon.png', content: png }, { path: 'src-tauri/icons/icon.ico', content: iconIco(png, 64) }, { path: 'src-tauri/src/main.rs', content: mainRs(o) }, { path: 'src-tauri/src/lib.rs', content: libRs(o) }, { path: 'src-tauri/src/site.rs', content: siteRs(o) }, { path: 'src-tauri/src/plugins.rs', content: pluginsRs(o) }, { path: 'src-tauri/src/desktop.rs', content: desktopRs(o) }, { path: 'src-tauri/tests/security.rs', content: securityRs(o) }, // Replaced by `npm run build`; here so `cargo check` works before the first build. { path: APP_OUTPUTS.runtime, content: lines('// Placeholder: `npm run build` writes the page runtime here (src/runtime/main.ts).') }, ]; }