waybetter/packages/create-waybetter/src/app.js
CRT 74c14554c4 waybetter 0.7.0, create-waybetter 0.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:57:36 +07:00

1328 lines
62 KiB
JavaScript

// `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 <waybetter repository or crates/waybetter-host>.');
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<typeof normalizeAppOptions>} 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-<command>` 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.<runtime_global> 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<String>,',
' title: Option<String>,',
' account: Option<Account>,',
' #[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<Account>,',
'}',
'',
'/// 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<String> {',
' 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<HTMLAnchorElement>(SELECTORS.accountLink);',
' if (!link) return null;',
" const handle = (link.getAttribute('href') ?? '').replace(/^\\/+@?/, '').split(/[/?#]/)[0] ?? '';",
' if (!handle) return null;',
' const avatar = root.querySelector<HTMLImageElement>(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<HTMLAnchorElement>(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.<runtimeGlobal>` (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<Flags>[] = [];',
'',
'/** 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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="9" fill="${o.site.accent}"/></svg>`)}`)};`,
'',
'function install(): void {',
' if (window.top !== window || !isSiteUrl(SITE, location.href)) return;',
' if ((window as unknown as Record<string, unknown>)[SITE.runtimeGlobal]) return;',
' configureRuntime(SITE);',
' const invoke = pageInvoke(window, SITE.product);',
' let registry: FeatureRegistry<CorePluginApi> | null = null;',
' const flags = createFeatureFlags<Flags>({',
' 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/<tag>.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(<Shell/>);",
);
}
/** @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<string, string> = {',
" '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<FeatureModule<CorePluginApi>> = [',
' {',
" 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<void>;',
' toggleMaximize(): Promise<void>;',
' close(): Promise<void>;',
" startResizeDragging(direction: 'North' | 'South' | 'East' | 'West' | 'NorthEast' | 'NorthWest' | 'SouthEast' | 'SouthWest'): Promise<void>;",
' startDragging(): Promise<void>;',
' isMaximized(): Promise<boolean>;',
' onResized(handler: () => void): Promise<() => void>;',
' setBackgroundColor(color: [number, number, number, number]): Promise<void>;',
'};',
'',
'interface Window {',
' __TAURI__: {',
' core: { invoke<T = unknown>(command: string, args?: Record<string, unknown>): Promise<T> };',
' event: { listen<T>(event: string, handler: (event: { payload: T }) => void): Promise<() => void> };',
' window: { getCurrentWindow(): TauriWindow };',
' };',
'}',
);
}
/** @param {AppOptions} o */
function indexHtml(o) {
return lines(
'<!doctype html>',
'<html lang="en">',
'<head>',
' <meta charset="utf-8">',
' <meta name="viewport" content="width=device-width, initial-scale=1">',
' <meta name="color-scheme" content="dark light">',
` <title>${o.product.replace(/[<&]/g, ch => (ch === '<' ? '&lt;' : '&amp;'))}</title>`,
' <!-- The chrome colors of the last theme, before the first frame (waybetter/shell/frame/boot). -->',
' <script src="./boot.js"></script>',
' <link rel="stylesheet" href="./shell.css">',
' <script type="module" src="./shell.js"></script>',
'</head>',
'<body>',
' <div id="root"></div>',
'</body>',
'</html>',
);
}
/** @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).') },
];
}