runtime: the site runtime's composition (siteRuntime, pageStrings, panelButton, commandReport)

What every app's page runtime entry did by hand (moved from wayBetter for X's inject/entry.tsx):

- siteRuntime.ts: pageInvoke (the IPC bridge read at call time), createFeatureFlags (parts that
  install on enable and dispose on disable, the flags native code delivers, a deck column's
  overrides on top of them, recorded at once and applied when <html> exists) and
  installSiteRuntime (the runtime object with the members native code calls, the plugin API views,
  developer-mode errors, its window names, the queued native deliveries, the command report).
- pageStrings.ts: the page's language (English built in, deliveries checked as data, the
  bundled-plugin namespace rule).
- panelButton.ts: the way into the panel from the page (the site's own navigation entry when the
  adapter mounts one, else a floating button that hides under the site's overlays and sits above
  its bottom bar).
- commandReport.ts: the palette command report and developer-mode start errors.
- tests/siteRuntime.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-30 14:28:54 +07:00
parent f6a50602d7
commit d3ca1d7b16
5 changed files with 688 additions and 0 deletions

View file

@ -0,0 +1,47 @@
// Plugin palette commands: a site tab tells the shell which commands its plugins registered, so the
// command palette can list them under each plugin's name. Only `{ id, title, keywords }` strings
// travel (capped in the registry and again natively, waybetter-host `plugins::commands`), only
// about this tab, only when the list changes (`tab_report.commands`). Running one is the shell
// asking native code to call `api.commands.run(id)` in this page; `run` never leaves it.
//
// Developer mode's start failures are reported here too: a toast in the page with file and line.
import type { CommandInfo } from './commands.ts';
import { devErrorLocation, formatDevError } from './devErrors.ts';
export type Invoke = (command: string, args: Record<string, unknown>) => Promise<unknown>;
type Commands = { list(): CommandInfo[]; onChange(callback: (list: CommandInfo[]) => void): () => void };
/** Reports the command list after it changes (batched, only real changes). Returns the disposer. */
export function installCommandReport(commands: Commands, invoke: Invoke | undefined, delay = 80): () => void {
if (!invoke) return () => {};
let last = '[]';
let timer: ReturnType<typeof setTimeout> | undefined;
const send = () => {
timer = undefined;
const list = commands.list();
const json = JSON.stringify(list);
if (json === last) return;
last = json;
invoke('tab_report', { report: { commands: list } }).catch(() => {});
};
const off = commands.onChange(() => {
if (timer === undefined) timer = setTimeout(send, delay);
});
return () => { off(); if (timer !== undefined) clearTimeout(timer); };
}
type Toast = (message: string, options: { tone: 'danger'; duration: number }) => void;
/**
* Developer mode (live reload): a plugin whose start failed shows `file:line — message` as a toast
* in this page. The host evaluates reloaded plugins with a `sourceURL`, so the line is the file's.
* `text` wraps the location in the app's words ("Plugin failed: {error}").
*/
export function devErrorReporter(toast: () => Toast | undefined, text: (error: string) => string) {
return (file: unknown, error: unknown) => {
if (typeof file !== 'string') return;
const location = formatDevError(devErrorLocation(error, file));
try { toast()?.(text(location), { tone: 'danger', duration: 12000 }); } catch { /* the page keeps working */ }
};
}

View file

@ -0,0 +1,100 @@
// The page runtime's language. Native code hands site tabs the owner's language and exactly the
// strings the page needs (the app's own page strings, the plugin UI kit's `kit.*` and the bundled
// plugins' `plugin.*`; waybetter-host `i18n::page_script`) as `<global>.i18n.set({ locale, dir,
// messages })`, queued in `__wbI18n` until the runtime exists. The messages arrive already resolved
// along the language's fallback chain by the shell; English is built into the runtime (the build
// keeps only those prefixes of the app's catalog). A switch applies live: `onChange` listeners
// re-render. Everything delivered is data: checked, bounded and inserted as text.
import { formatMessage, normalizeLocale, type Params } from './i18n.ts';
import { pluginNamespace, type I18nSource } from './pluginI18n.ts';
export type PageStringsOptions = {
/** English for every string the page shows (the app's page strings and the framework's kit words). */
english: Readonly<Record<string, string>>;
/** Key prefixes a delivery may carry (`['x.', 'kit.', 'plugin.']`); other keys are dropped. */
prefixes: readonly string[];
/** Console label (the app's name). */
label: string;
/**
* The file-name prefix the app installs its bundled plugins with (`bt-` for `bt-<name>.plugin.js`)
* when their strings are keyed by the source name (`plugin.<name>.*`).
*/
bundledPrefix?: string;
};
export type PageStrings = {
/** The active language's message for `key`, else English. */
message(key: string): string | undefined;
/** A runtime string: `t('x.media.download')`. Placeholder values are inserted as text. */
t(key: string, params?: Params): string;
locale(): string;
/** Native code delivers `{ locale, dir, messages }`; anything malformed is ignored (English stays). */
set(value: unknown): void;
onChange(listener: () => void): () => void;
/** The plugin API's view of the language (pluginI18n.ts): `api.i18n` and the UI kit's words. */
source: I18nSource;
/** A plugin file's catalog namespace (`plugin.<namespace>.*`). */
pluginNamespace(id: string): string;
};
/** A delivery holds at most this many messages, each at most this long. */
export const PAGE_STRING_LIMITS = Object.freeze({ keys: 4000, value: 2000 });
export function createPageStrings(options: PageStringsOptions): PageStrings {
const { english, prefixes, label, bundledPrefix } = options;
let delivered: Record<string, string> = {};
let active = 'en';
const listeners = new Set<() => void>();
const message = (key: string) => delivered[key] ?? english[key];
const t = (key: string, params?: Params) => {
const text = message(key);
return text === undefined ? key : formatMessage(text, params ?? {}, active);
};
function set(value: unknown) {
if (!value || typeof value !== 'object') return;
const input = value as { locale?: unknown; messages?: unknown };
const tag = normalizeLocale(input.locale);
if (!tag || !input.messages || typeof input.messages !== 'object') return;
const next: Record<string, string> = {};
let count = 0;
for (const [key, text] of Object.entries(input.messages as Record<string, unknown>)) {
if (count >= PAGE_STRING_LIMITS.keys) break;
if (typeof text !== 'string' || text.length > PAGE_STRING_LIMITS.value || !prefixes.some(prefix => key.startsWith(prefix))) continue;
next[key] = text;
count += 1;
}
const changed = tag !== active || JSON.stringify(next) !== JSON.stringify(delivered);
delivered = next;
active = tag;
if (!changed) return;
for (const listener of [...listeners]) {
try { listener(); } catch (error) { console.error(`[${label}] language listener failed`, error); }
}
}
const onChange = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
return {
message,
t,
locale: () => active,
set,
onChange,
source: { locale: () => active, message, onChange },
// Bundled plugins are installed as `<bundledPrefix><name>.plugin.js`, but their strings are keyed
// by their source name: without this, every bundled plugin would stay English.
pluginNamespace(id) {
const namespace = pluginNamespace(id);
if (!bundledPrefix || !namespace.startsWith(bundledPrefix)) return namespace;
const bare = namespace.slice(bundledPrefix.length);
const prefix = `plugin.${bare}.`;
return Object.keys(english).some(key => key.startsWith(prefix)) ? bare : namespace;
},
};
}

View file

@ -0,0 +1,97 @@
// The way from a site page into the app's panel. The site adapter puts an entry into the site's own
// navigation when it has one (`mountNav`: X clones its "More" item); where the page draws no
// navigation (a full-pane viewer opened by URL, signed-out pages, a mobile layout) a small floating
// button stands in. The button must never cover the site's own UI: it hides while the site layers
// something over the page and sits above the site's bottom bar. Both are re-checked on every DOM
// change through the shared DOM hub.
import { watchDom, type DomWindow } from './domHub.ts';
/** Gap between the button and the pane edge or the site's bottom bar. */
export const BUTTON_GAP = 16;
/**
* Pure: the button's `bottom` offset. `barTop` is the top edge of the site's bottom bar, null when
* it has none; a zero-height or implausibly tall bar (a desktop layout may keep an empty one at the
* bottom edge) adds nothing.
*/
export function buttonBottom(barTop: number | null, viewportHeight: number, gap = BUTTON_GAP): number {
if (barTop === null) return gap;
const bar = viewportHeight - barTop;
return bar > 0 && bar < viewportHeight / 3 ? Math.round(bar) + gap : gap;
}
export type PanelButtonSite = {
/** The floating button's DOM id. */
id: string;
/** The app's logo (an image URL, usually a data URI: the site's CSP may block others). */
logo: string;
/** The button's tooltip and accessible name, in the owner's language. */
title(): string;
/** Puts the entry into the site's own navigation (re-attaching it when the site re-renders); true while it is there. */
mountNav?(open: () => void): boolean;
/** Pages where the button stays hidden (a full-pane photo or video viewer whose actions sit at the bottom). */
hiddenOn?(pathname: string): boolean;
/** The site is showing a modal sheet or a dialog layer: the button hides under it. */
overlaySelector?: string;
/** The site's bottom bar (a mobile layout): the button sits above it. */
bottomBarSelector?: string;
};
/** Pure: whether the button stays hidden on this route / with a site overlay open. */
export function buttonHidden(site: Pick<PanelButtonSite, 'hiddenOn'>, pathname: string, overlayOpen: boolean): boolean {
return overlayOpen || !!site.hiddenOn?.(pathname);
}
/**
* Keeps the panel entry on the page: the site's navigation item when `mountNav` places one, else
* the floating button. `open` opens the panel (the runtime's `open()`).
*/
export function installPanelButton(win: Window & typeof globalThis, site: PanelButtonSite, open: () => void): { sync(): void } {
const doc = win.document;
const create = (): HTMLButtonElement => {
const button = doc.createElement('button');
button.id = site.id;
button.type = 'button';
button.title = site.title();
button.setAttribute('aria-label', button.title);
const img = doc.createElement('img');
img.src = site.logo;
img.alt = '';
Object.assign(img.style, { width: '100%', height: '100%', borderRadius: '22%', display: 'block' });
button.appendChild(img);
Object.assign(button.style, {
position: 'fixed', left: '16px', bottom: '16px', zIndex: '2147483646', width: '40px', height: '40px',
padding: '0', border: '0', background: 'transparent', cursor: 'pointer', boxShadow: '0 4px 14px rgba(0,0,0,.35)', borderRadius: '22%',
});
button.addEventListener('click', () => open());
return button;
};
const syncButton = (navMounted: boolean) => {
const existing = doc.getElementById(site.id);
if (navMounted) { existing?.remove(); return; }
const button = existing ?? create();
const overlay = site.overlaySelector ? !!doc.querySelector(site.overlaySelector) : false;
const barTop = site.bottomBarSelector ? doc.querySelector(site.bottomBarSelector)?.getBoundingClientRect().top ?? null : null;
const display = buttonHidden(site, win.location.pathname, overlay) ? 'none' : 'block';
const bottom = `${buttonBottom(barTop, win.innerHeight)}px`;
if (button.style.display !== display) button.style.display = display;
if (button.style.bottom !== bottom) button.style.bottom = bottom;
if (!existing) doc.body.appendChild(button);
};
const sync = () => {
if (!doc.body) return;
syncButton(site.mountNav?.(open) ?? false);
};
const start = () => {
sync();
// Sites re-render their navigation on route and layout changes; re-attach when the entry disappears.
watchDom(win as unknown as DomWindow, sync);
};
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', start, { once: true });
else start();
return { sync };
}

View file

@ -0,0 +1,259 @@
// The page runtime's composition helpers: what every app's runtime entry does the same way. An
// app's entry (injected at document start into its site's tabs) installs its features and then:
//
// const invoke = pageInvoke(window, SITE.product);
// const flags = createFeatureFlags({ defaults, label: SITE.product, installers, apply });
// const runtime = installSiteRuntime({ win: window, site: SITE, version, invoke, plugins, strings, features: flags, ... });
// installPanelButton(window, button, () => runtime.open()); // panelButton.ts
//
// - `pageInvoke`: the IPC bridge, read at call time.
// - `createFeatureFlags`: flag-controlled parts that install on enable and dispose on disable, the
// flags native code delivers (`features_set`) and a deck column's overrides on top of them.
// - `installSiteRuntime`: the runtime object under its window names (`window.wayBetter` and the
// app's own), the members native code calls (features, theme, fonts, pane, links, i18n, column),
// the plugin API views, developer-mode errors, the palette command report, and whatever native
// code delivered before the runtime existed (the `__bt*` / `__wb*` queues).
import { installCommandReport, devErrorReporter } from './commandReport.ts';
import type { CommandInfo } from './commands.ts';
import type { PageStrings } from './pageStrings.ts';
import type { PluginContext } from './permissions.ts';
import type { Params } from './i18n.ts';
import type { SiteAdapter } from './site.ts';
export type PageInvoke = (command: string, args: Record<string, unknown>) => Promise<unknown>;
type BridgeWindow = { __TAURI_INTERNALS__?: { invoke(cmd: string, args?: Record<string, unknown>): Promise<unknown> }; navigator: { webdriver?: boolean } };
/**
* Native commands from the page. The IPC bridge is read at call time: a runtime installed at
* document start can run before Tauri has put `__TAURI_INTERNALS__` on the page. A runtime only
* ships inside the app's site tabs; automated test browsers (`navigator.webdriver`) have no
* bridge, and the features that need it stay off there (undefined).
*/
export function pageInvoke(win: Window, product: string): PageInvoke | undefined {
const target = win as unknown as BridgeWindow;
const inApp = !!target.__TAURI_INTERNALS__ || !target.navigator.webdriver;
if (!inApp) return undefined;
return (command, args) => {
const ipc = target.__TAURI_INTERNALS__;
return ipc ? ipc.invoke(command, args) : Promise.reject(new Error(`${product}: IPC bridge not ready`));
};
}
// ---------------------------------------------------------------------------------------------
// Feature flags
/** A flag-controlled part of the runtime, while it is installed. */
export type Installed = { dispose(): void };
export type FlagInstaller<Flags> = readonly [flag: keyof Flags & string, install: () => Installed];
export type FeatureFlagsOptions<Flags extends Record<string, boolean>> = {
/** Every flag with its default; a flag not in here is ignored. */
defaults: Flags;
/** Console label (the app's name). */
label: string;
/** Parts that install when their flag turns on and dispose when it turns off, in install order. */
installers: ReadonlyArray<FlagInstaller<Flags>>;
/** Every applied config, before the installers (flags that are CSS, or that another part reads). */
apply?(config: Partial<Flags>): void;
/** The page's document: a column's flags delivered at document start wait for `<html>` to exist. */
document?: Document;
};
export type FeatureFlags<Flags extends Record<string, boolean>> = {
/** The flags native code delivers (the owner's tweaks); a deck column's overrides stay on top. */
apply(config: Partial<Flags>): void;
/**
* A deck column's flag overrides (allow-listed natively; unknown keys and non-booleans are
* dropped here too). Flags a column stops overriding go back to the global value, or the default
* when the global config never named them. Recorded at once; applied now, or once the document
* has its `<html>` (a new column webview gets its config at document start).
*/
setColumnFlags(flags: unknown): void;
/** Turns one installer on or off directly (a kill switch). */
set(flag: keyof Flags & string, enabled: boolean): void;
/** Whether a flag's installer is installed right now. */
installed(flag: keyof Flags & string): boolean;
};
export function createFeatureFlags<Flags extends Record<string, boolean>>(options: FeatureFlagsOptions<Flags>): FeatureFlags<Flags> {
const { defaults, label, installers } = options;
const active = new Map<string, Installed>();
const installerOf = new Map(installers.map(([flag, install]) => [flag as string, install]));
const set = (flag: string, enabled: boolean) => {
const install = installerOf.get(flag);
if (!install) return;
const current = active.get(flag);
if (enabled && !current) {
try { active.set(flag, install()); }
catch (error) { console.error(`[${label}] ${flag} failed`, error); }
} else if (!enabled && current) {
current.dispose();
active.delete(flag);
}
};
// Installed at document start, before the site's bundle runs (request gates wrap its fetch).
for (const [flag] of installers) set(flag, defaults[flag]);
let globalFlags: Partial<Flags> = {};
let columnFlags: Partial<Flags> = {};
const merged = (previous: Partial<Flags> = {}): Partial<Flags> => {
const restore = Object.fromEntries(Object.keys(previous).map(key => [key, defaults[key]])) as Partial<Flags>;
return { ...restore, ...globalFlags, ...columnFlags };
};
const applyAll = (config: Partial<Flags>) => {
options.apply?.(config);
for (const [flag] of installers) {
const enabled = config[flag];
if (typeof enabled === 'boolean') set(flag, enabled);
}
};
return {
apply(config) {
globalFlags = { ...globalFlags, ...config };
applyAll(Object.keys(columnFlags).length ? merged() : config);
},
setColumnFlags(flags) {
const next: Partial<Flags> = {};
if (flags && typeof flags === 'object') {
for (const [key, value] of Object.entries(flags)) if (key in defaults && typeof value === 'boolean') (next as Record<string, boolean>)[key] = value;
}
const previous = columnFlags;
columnFlags = next;
if (!Object.keys(previous).length && !Object.keys(next).length) return;
const apply = () => { try { applyAll(merged(previous)); } catch (error) { console.error(`[${label}] column flags failed`, error); } };
const doc = options.document;
if (!doc || doc.documentElement) apply();
else doc.addEventListener('DOMContentLoaded', apply, { once: true });
},
set,
installed: flag => active.has(flag),
};
}
// ---------------------------------------------------------------------------------------------
// The runtime object
/** What native code and plugins reach through `window.<runtimeGlobal>`. */
export type SiteRuntime<Api extends object> = {
version: string;
/** Running plugins by file (native start/stop scripts manage this). */
plugins: Record<string, { start?: () => void; stop?: () => void }>;
/** Load errors by plugin file. */
errors: Record<string, string>;
/** Native code hands over the feature flags (`features_set`). */
features: { apply(config: unknown): void };
/** Native code hands over the owner's language: `{ locale, dir, messages }` (pageStrings.ts). */
i18n: { set(strings: unknown): void; locale(): string; t(key: string, params?: Params): string };
/** Native code hands over where clicked site pages open (`{ site: 'same' | 'pane' | 'tab' | 'browser' }`). */
links: { configure(value: unknown): void };
/** Native code says whether this pane's navigation repeats another pane's (`{ redundantNav }`). */
pane: { set(state: unknown): void };
/** Native code hands over downloaded Google Fonts (validated woff2 bytes) that this tab's theme uses. */
fonts: { provide(fonts: unknown): void };
/** Power User Mode: native code hands a deck column its config (null = an ordinary tab again). */
column: { set(config: unknown): void; markRead(): void; jumpToUnread(): void; unread(): number };
/** Opens the app's panel in the shell (at a section). */
open(section?: string): void;
/**
* Helpers for `*.plugin.js` files. Read while a plugin's code runs (its module body, constructor,
* start() and stop()), this is that plugin's view for its declared `@permissions`; elsewhere it
* is the full API.
*/
readonly api: Api;
/** Runs `fn` as plugin `id` with `grants` (null = legacy, full API; undefined = as registered). Native start/stop scripts call it. */
withPlugin<R>(id: string, grants: readonly string[] | null | undefined, fn: () => R): R;
/** Developer mode: a reloaded plugin failed to start (native `dev_start_script`); shown as a toast. */
devError(file: unknown, error: unknown): void;
};
type ApiWithUi = { ui?: { toast(message: string, options: { tone: 'danger'; duration: number }): unknown }; commands?: { list(): CommandInfo[]; onChange(callback: (list: CommandInfo[]) => void): () => void } };
export type SiteRuntimeOptions<Api extends object, Extra extends object> = {
win: Window & typeof globalThis;
site: Pick<SiteAdapter, 'product' | 'runtimeGlobal'>;
version: string;
/** Other window names for the runtime, besides `wayBetter` and `site.runtimeGlobal` (names older plugins use). */
aliases?: readonly string[];
invoke?: PageInvoke;
/** Per-plugin API views (permissions.ts `createPluginContext`). */
plugins: PluginContext<Api>;
strings: PageStrings;
/** The toast for a plugin that failed to start in developer mode ("Plugin failed: {error}"). */
pluginFailed(error: string): string;
features?: { apply(config: never): void };
/** The theme handle (themeRuntime.ts), exposed as `theme`. */
theme?: { apply(choice: unknown): void };
fonts?(fonts: unknown): void;
pane?: { set(state: unknown): void };
links?: { configure(value: unknown): void };
column?: SiteRuntime<Api>['column'];
/** The app's own members (X: alerts, space, fixes). */
extra?: Extra;
/** Values native code may have delivered for the app's own members before the runtime existed: queue global and receiver. */
queues?: ReadonlyArray<readonly [queue: string, deliver: (value: unknown) => void]>;
};
/**
* Builds the runtime object, publishes it under its window names (not writable, not configurable),
* takes over what native code queued before it existed, and starts the palette command report.
*/
export function installSiteRuntime<Api extends object, Extra extends object = Record<never, never>>(options: SiteRuntimeOptions<Api, Extra>): SiteRuntime<Api> & Extra {
const { win, site, plugins, strings } = options;
const label = site.product;
// A failure in one member never breaks native code's other deliveries (or the page).
const guarded = <T>(what: string, fn: (value: T) => void) => (value: T) => {
try { fn(value); } catch (error) { console.error(`[${label}] ${what} failed`, error); }
};
const runtime = {
...(options.extra ?? {}),
version: options.version,
plugins: {},
errors: {},
features: { apply: (config: unknown) => (options.features as { apply(config: unknown): void } | undefined)?.apply(config) },
...(options.theme ? { theme: options.theme } : {}),
fonts: { provide: guarded('fonts', (list: unknown) => options.fonts?.(list)) },
pane: { set: guarded('split navigation', (state: unknown) => options.pane?.set(state)) },
links: { configure: (value: unknown) => options.links?.configure(value) },
i18n: { set: guarded('language', (value: unknown) => strings.set(value)), locale: strings.locale, t: strings.t },
column: {
set: guarded('deck column', (config: unknown) => options.column?.set(config)),
markRead: () => options.column?.markRead(),
jumpToUnread: () => options.column?.jumpToUnread(),
unread: () => options.column?.unread() ?? 0,
},
open(section?: string) {
(win as unknown as BridgeWindow).__TAURI_INTERNALS__?.invoke('open_panel', { section: section ?? null }).catch(() => {});
},
withPlugin: <R>(id: string, grants: readonly string[] | null | undefined, fn: () => R) => plugins.run(id, grants, fn),
get api() { return plugins.current(); },
devError: devErrorReporter(() => {
const ui = (plugins.current() as ApiWithUi).ui;
return ui ? (message, toastOptions) => { ui.toast(message, toastOptions); } : undefined;
}, options.pluginFailed),
} as unknown as SiteRuntime<Api> & Extra & { theme?: { apply(choice: unknown): void } };
// `wayBetter` is the documented name; the app's own names stay for existing plugins.
const names = [...new Set(['wayBetter', site.runtimeGlobal, ...(options.aliases ?? [])])];
for (const name of names) Object.defineProperty(win, name, { value: runtime, configurable: false, writable: false });
// Native code may deliver before this runtime exists.
const queued = win as unknown as Record<string, unknown>;
const take = (queue: string, deliver: (value: unknown) => void) => { if (queued[queue]) deliver(queued[queue]); };
take('__wbI18n', runtime.i18n.set);
take('__btFeatures', runtime.features.apply);
take('__wbFonts', runtime.fonts.provide);
take('__btTheme', value => runtime.theme?.apply(value));
take('__btPane', runtime.pane.set);
take('__btLinks', runtime.links.configure);
take('__btColumn', runtime.column.set);
for (const [queue, deliver] of options.queues ?? []) take(queue, deliver);
// Plugin commands for the shell's command palette: ids, titles and keywords about this tab only.
const commands = (plugins.current() as ApiWithUi).commands;
if (commands) installCommandReport(commands, options.invoke);
return runtime;
}

View file

@ -0,0 +1,185 @@
// The runtime composition helpers: the IPC bridge read at call time, flag-controlled parts with a
// deck column's overrides, the runtime object under its window names with what native code queued,
// the page strings native code delivers, the palette command report and the panel button's placement.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { devErrorReporter, installCommandReport } from '../src/commandReport.ts';
import { createPageStrings, PAGE_STRING_LIMITS } from '../src/pageStrings.ts';
import { BUTTON_GAP, buttonBottom, buttonHidden } from '../src/panelButton.ts';
import { createPluginContext } from '../src/permissions.ts';
import { createFeatureFlags, installSiteRuntime, pageInvoke } from '../src/siteRuntime.ts';
const tick = (ms = 15) => new Promise(resolve => setTimeout(resolve, ms));
const SITE = { product: 'wayBetter for Test', runtimeGlobal: 'Demo' };
test('pageInvoke: the bridge is read at call time; a test browser without one has no invoke', async () => {
const win = { navigator: { webdriver: false } } as unknown as Window & { __TAURI_INTERNALS__?: unknown };
const invoke = pageInvoke(win, 'Demo')!;
await assert.rejects(invoke('ping', {}), /Demo: IPC bridge not ready/);
win.__TAURI_INTERNALS__ = { invoke: async (command: string, args: unknown) => ({ command, args }) };
assert.deepEqual(await invoke('ping', { a: 1 }), { command: 'ping', args: { a: 1 } });
assert.equal(pageInvoke({ navigator: { webdriver: true } } as unknown as Window, 'Demo'), undefined);
});
type Flags = { gate: boolean; pill: boolean; css: boolean };
test('feature flags: parts install on enable and dispose on disable, in order, and a failing part is contained', () => {
const log: string[] = [];
const part = (name: string) => () => { log.push(`+${name}`); return { dispose: () => { log.push(`-${name}`); } }; };
const applied: Array<Partial<Flags>> = [];
const errors: unknown[] = [];
const original = console.error;
console.error = (...args: unknown[]) => { errors.push(args[0]); };
try {
const flags = createFeatureFlags<Flags>({
defaults: { gate: true, pill: false, css: true },
label: 'Demo',
installers: [['gate', part('gate')], ['pill', () => { throw new Error('no pill'); }]],
apply: config => applied.push(config),
});
assert.deepEqual(log, ['+gate'], 'default-on parts install at once');
assert.ok(flags.installed('gate') && !flags.installed('pill'));
flags.apply({ gate: false, css: false });
assert.deepEqual(log, ['+gate', '-gate']);
assert.deepEqual(applied, [{ gate: false, css: false }]);
flags.apply({ gate: true, pill: true });
assert.deepEqual(log, ['+gate', '-gate', '+gate']);
assert.equal(flags.installed('pill'), false);
assert.match(String(errors[0]), /\[Demo\] pill failed/);
flags.set('gate', false);
assert.equal(flags.installed('gate'), false, 'a kill switch');
flags.set('css', true);
} finally { console.error = original; }
});
test('feature flags: a deck column overrides global flags; dropped overrides go back to the global value or the default', () => {
const applied: Array<Partial<Flags>> = [];
const flags = createFeatureFlags<Flags>({ defaults: { gate: true, pill: false, css: true }, label: 'Demo', installers: [], apply: config => applied.push(config) });
flags.apply({ pill: true });
flags.setColumnFlags({ pill: false, css: false, unknown: true, gate: 'yes' });
assert.deepEqual(applied.at(-1), { pill: false, css: false }, 'only known booleans; the column wins');
flags.apply({ css: true });
assert.deepEqual(applied.at(-1), { pill: false, css: false }, 'a global change does not undo the column');
flags.setColumnFlags({ pill: false });
assert.deepEqual(applied.at(-1), { pill: false, css: true }, 'css is the global value again');
flags.setColumnFlags(null);
assert.deepEqual(applied.at(-1), { pill: true, css: true }, 'an ordinary tab again: the global flags');
const before = applied.length;
flags.setColumnFlags(undefined);
assert.equal(applied.length, before, 'nothing to change');
// Delivered at document start, a column's flags wait for <html>.
const listeners: Array<() => void> = [];
const doc = { documentElement: null as unknown, addEventListener: (_: string, listener: () => void) => { listeners.push(listener); } };
const late: Array<Partial<Flags>> = [];
const waiting = createFeatureFlags<Flags>({ defaults: { gate: true, pill: false, css: true }, label: 'Demo', installers: [], apply: config => late.push(config), document: doc as unknown as Document });
waiting.setColumnFlags({ css: false });
assert.deepEqual(late, []);
waiting.apply({ pill: true });
assert.deepEqual(late.at(-1), { pill: true, css: false }, 'recorded at once: a global delivery already respects it');
listeners[0]!();
assert.deepEqual(late.at(-1), { pill: true, css: false });
});
function runtimeWindow(queued: Record<string, unknown> = {}) {
return { navigator: { webdriver: false }, ...queued } as unknown as Window & typeof globalThis & Record<string, unknown>;
}
test('the runtime is published under its names, takes what native code queued and reports commands', async () => {
const seen: Record<string, unknown> = {};
const sent: unknown[] = [];
const commandListeners: Array<(list: unknown[]) => void> = [];
let list = [{ id: 'a.plugin.js:go', title: 'Go', keywords: '' }];
const toasts: string[] = [];
const api = { ui: { toast: (message: string) => { toasts.push(message); } }, commands: { list: () => list, onChange: (callback: (next: unknown[]) => void) => { commandListeners.push(callback); return () => {}; } }, i18n: {} };
const strings = createPageStrings({ english: { 'demo.hello': 'Hello {name}' }, prefixes: ['demo.'], label: 'Demo' });
const win = runtimeWindow({
__wbI18n: { locale: 'tr', messages: { 'demo.hello': 'Merhaba {name}', 'other.key': 'dropped' } },
__btFeatures: { gate: false },
__btLinks: { site: 'tab' },
__btColumn: { source: 'home' },
__demoExtra: ['x'],
});
const runtime = installSiteRuntime({
win, site: SITE, version: '1.2.3', aliases: ['OldName'],
invoke: async (command, args) => { sent.push([command, args]); },
plugins: createPluginContext(api),
strings,
pluginFailed: error => `Plugin failed: ${error}`,
features: { apply: (config: unknown) => { seen.features = config; } },
links: { configure: value => { seen.links = value; } },
column: { set: config => { seen.column = config; }, markRead() {}, jumpToUnread() {}, unread: () => 3 },
extra: { demo: { set: (value: unknown) => { seen.extra = value; } } },
queues: [['__demoExtra', value => { seen.extra = value; }]],
});
for (const name of ['wayBetter', 'Demo', 'OldName']) {
assert.equal(win[name], runtime, name);
assert.deepEqual([Object.getOwnPropertyDescriptor(win, name)!.writable, Object.getOwnPropertyDescriptor(win, name)!.configurable], [false, false]);
}
assert.equal(runtime.version, '1.2.3');
assert.deepEqual(seen, { features: { gate: false }, links: { site: 'tab' }, column: { source: 'home' }, extra: ['x'] });
assert.equal(runtime.i18n.locale(), 'tr');
assert.equal(runtime.i18n.t('demo.hello', { name: 'Ada' }), 'Merhaba Ada');
assert.equal(strings.message('other.key'), undefined, 'only the runtime\'s prefixes are taken');
assert.equal(runtime.column.unread(), 3);
assert.equal(runtime.api, api, 'outside a plugin: the full API');
assert.equal(runtime.withPlugin('a.plugin.js', null, () => runtime.api), api, 'a legacy plugin: the full API');
// A member that throws never breaks the others.
const errors: unknown[] = [];
const original = console.error;
console.error = (...args: unknown[]) => { errors.push(args[0]); };
try {
const broken = installSiteRuntime({ win: runtimeWindow(), site: { product: 'Demo', runtimeGlobal: 'Broken' }, version: '1', plugins: createPluginContext(api), strings, pluginFailed: String, fonts: () => { throw new Error('bad fonts'); } });
broken.fonts.provide([]);
assert.match(String(errors[0]), /\[Demo\] fonts failed/);
} finally { console.error = original; }
// Developer mode: a plugin that failed to start shows file:line in a toast.
runtime.devError('a.plugin.js', Object.assign(new Error('boom'), { stack: 'Error: boom\n at start (wb-dev/a.plugin.js:12:5)' }));
assert.match(toasts[0] ?? '', /^Plugin failed: a\.plugin\.js:\d+/);
// The palette hears about commands only when they change.
list = [...list, { id: 'a.plugin.js:stop', title: 'Stop', keywords: 'halt' }];
commandListeners.forEach(listener => listener(list));
await tick(120);
assert.deepEqual(sent, [['tab_report', { report: { commands: list } }]]);
});
test('page strings: deliveries are data (prefix, type and length checked), English is the fallback', () => {
const strings = createPageStrings({ english: { 'x.a': 'A', 'kit.ok': 'OK', 'plugin.clean-links.name': 'Clean links' }, prefixes: ['x.', 'kit.', 'plugin.'], label: 'Demo', bundledPrefix: 'bt-' });
let changes = 0;
const off = strings.onChange(() => { changes += 1; });
strings.set(null);
strings.set({ locale: 'not a tag!', messages: {} });
strings.set({ locale: 'de' });
assert.equal(strings.locale(), 'en');
strings.set({ locale: 'de', messages: { 'x.a': 'Ä', 'x.long': 'l'.repeat(PAGE_STRING_LIMITS.value + 1), 'x.n': 5, 'native.secret': 'no' } });
assert.equal(changes, 1);
assert.deepEqual([strings.locale(), strings.t('x.a'), strings.t('kit.ok'), strings.message('x.long'), strings.message('native.secret'), strings.t('x.unknown')], ['de', 'Ä', 'OK', undefined, undefined, 'x.unknown']);
strings.set({ locale: 'de', messages: { 'x.a': 'Ä' } });
assert.equal(changes, 1, 'the same delivery changes nothing');
assert.equal(strings.source.message('x.a'), 'Ä');
// Bundled plugins are installed with a prefix; their strings are keyed by the source name.
assert.equal(strings.pluginNamespace('bt-clean-links.plugin.js'), 'clean-links');
assert.equal(strings.pluginNamespace('bt-mine.plugin.js'), 'bt-mine', 'an owner\'s file that only looks bundled keeps its name');
assert.equal(strings.pluginNamespace('other.plugin.js'), 'other');
off();
strings.set({ locale: 'fr', messages: {} });
assert.equal(changes, 1);
});
test('command report and developer errors without a bridge or a toast do nothing', () => {
assert.doesNotThrow(() => installCommandReport({ list: () => [], onChange: () => () => {} }, undefined)());
assert.doesNotThrow(() => devErrorReporter(() => undefined, String)('a.plugin.js', new Error('x')));
assert.doesNotThrow(() => devErrorReporter(() => () => { throw new Error('toast broke'); }, String)('a.plugin.js', new Error('x')));
assert.doesNotThrow(() => devErrorReporter(() => () => {}, String)(42, new Error('x')));
});
test('the panel button sits above a bottom bar, never over a viewer or under an overlay', () => {
assert.equal(buttonBottom(null, 800), BUTTON_GAP);
assert.equal(buttonBottom(747, 800), 53 + BUTTON_GAP, 'above a 53px bottom bar');
assert.equal(buttonBottom(800, 800), BUTTON_GAP, 'an empty bar at the bottom edge adds nothing');
assert.equal(buttonBottom(100, 800), BUTTON_GAP, 'an implausibly tall bar adds nothing');
const site = { hiddenOn: (pathname: string) => /\/photo\/\d+$/.test(pathname) };
assert.equal(buttonHidden(site, '/a/status/1/photo/1', false), true);
assert.equal(buttonHidden(site, '/home', true), true);
assert.equal(buttonHidden(site, '/home', false), false);
assert.equal(buttonHidden({}, '/a/status/1/photo/1', false), false);
});