1328 lines
62 KiB
JavaScript
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 === '<' ? '<' : '&'))}</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).') },
|
|
];
|
|
}
|