mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
refactor(app): carve the plugin loader out of app.js into static/js/ (R3a) (#878)
The first carve, and deliberately the riskiest: app.js IS the plugin loader (the
R0 host rails), so it goes first while the module graph is still one edge deep.
static/js/plugin-loader.js (829 lines) — bodies VERBATIM. app.js 12,217 → 11,439.
Core's first `static/js/` module, exactly as constitution II anticipates.
CLOSURE (measured with acorn, not regex — brace-matching stripped source drifted):
the block at app.js:11246-12031 is contiguous and self-contained. It needs only
TWO things from the rest of app.js, and exports only TWO:
exports: loadPlugins (the window contract), bootstrapPluginsAndUi (boot)
inbound: window.showScreen — already the public host contract (constitution II),
so it is called through `window`, not re-coupled as an import
_populateVizPicker — injected via configurePluginLoader()
WHY A SEAM, NOT AN IMPORT. plugin-loader must not import app.js: app.js imports
it, so that would close a cycle. I checked whether _populateVizPicker could just
move into the module instead (which would delete the seam entirely) — it drags 9
further symbols (_canRun3D, _autoMatchViz, _showPromotionNag, …), i.e. a whole
viz cluster. That is its own carve, so the seam stays.
THE SEAM'S DEFAULT IS LOUD, ON PURPOSE. A no-op stub is the classic silent
failure for this pattern (see the editor's setHostHooks trap, hit twice): drop the
wiring call and the loader keeps working while the viz picker quietly stops
refreshing — no test, no boot check says a word. The default now console.errors,
so the smoke harness catches it. VERIFIED BY BITE TEST: removing
configurePluginLoader() from app.js surfaces
"[plugin-loader] host seam not configured" at boot. The seam IS exercised on the
plugin-startup path, so an unwired hook cannot pass silently.
no-cycle is now LIVE on core's own graph for the first time. eslint.config.js
gains `static/app.js` + `static/js/**` to the module block — app.js now `import`s,
so parsing it as a script would be a syntax error. VERIFIED BY BITE TEST: making
plugin-loader import app.js back fails with "Dependency cycle detected".
HARNESSES (the R3a note said budget one conversion per carve — it was five):
retargeted capability_inspector_nav, plugin_hydration_wipe,
plugin_loader_script_type, plugin_style_injection, legacy_shim_hits (SPLIT — one
test needs the loader, one still needs app.js) + test_plugin_runtime_idempotence.
legacy_shim_hits was missed by a symbol-name grep because it greps for a code
STRING; only the failing run found it. test_capability_events' NEGATIVE asserts
now span app.js + the loader — carving code out of app.js would otherwise make
them vacuous instead of failing.
VERIFIED: A/B against origin/main in two browsers — mounted plugin screens, 14
loaded plugin scripts, the 3 module plugins injected as <script type="module">,
37 capability participants, 14 shims, window.loadPlugins: IDENTICAL, zero
console/page errors on both. /static/js/plugin-loader.js serves 200; R0 rails
intact (src/main.js 200, conditional GET 304, script_type passthrough).
pytest 2396, node 1032/1032, ESLint 0, Codex 0.
Codex preflight caught a REAL [P1] first pass: static/js/plugin-loader.js was
untracked, so a checkout would have served an app.js importing a nonexistent
module — a failed static import kills the whole module and every window handler
with it. Now tracked.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
92c86f5393
commit
38772f604a
+12
-5
@@ -43,12 +43,19 @@ module.exports = [
|
|||||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||||
rules: { 'max-lines': sizeRule(1500) },
|
rules: { 'max-lines': sizeRule(1500) },
|
||||||
},
|
},
|
||||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
// ES-module graphs (a plugin's src/ tree, .mjs tests, core's own static/js/
|
||||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
// tree): module parsing + the acyclic-imports hard gate + the size norm. A
|
||||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
// migrated bundled plugin's entry `import './src/main.js'` screen.js must
|
||||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
// parse as a module — add its glob here in that plugin's migration PR
|
||||||
|
// (classic screen.js stays a script).
|
||||||
|
//
|
||||||
|
// `static/app.js` is listed explicitly: it is served as
|
||||||
|
// <script type="module"> (R3a) and now `import`s its carved-out modules, so
|
||||||
|
// parsing it as a script would be a syntax error. It is the ENTRY of core's
|
||||||
|
// module graph, which is what makes no-cycle meaningful here — a carved
|
||||||
|
// module that imports app.js back would close a cycle and fail this gate.
|
||||||
{
|
{
|
||||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js'],
|
||||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||||
plugins: { 'import-x': importX },
|
plugins: { 'import-x': importX },
|
||||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||||
|
|||||||
+9
-786
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
bootstrapPluginsAndUi,
|
||||||
|
configurePluginLoader,
|
||||||
|
loadPlugins,
|
||||||
|
} from './js/plugin-loader.js';
|
||||||
|
|
||||||
// Demo analytics — real impl set by demo.js; no-op in normal builds
|
// Demo analytics — real impl set by demo.js; no-op in normal builds
|
||||||
window.feedBackDemoTrack = window.feedBackDemoTrack ?? null;
|
window.feedBackDemoTrack = window.feedBackDemoTrack ?? null;
|
||||||
|
|
||||||
@@ -11243,792 +11249,6 @@ async function checkScanAndLoad() {
|
|||||||
loadLibrary();
|
loadLibrary();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plugin loader ───────────────────────────────────────────────────────
|
|
||||||
let _loadPluginsInFlight = false;
|
|
||||||
const _pluginUiContributions = new Map();
|
|
||||||
const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu';
|
|
||||||
|
|
||||||
function _capabilityInspectorNavEnabled() {
|
|
||||||
try { return localStorage.getItem(CAPABILITY_INSPECTOR_NAV_SETTING) === '1'; }
|
|
||||||
catch (_) { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive a display label from a (possibly string) nav value. `/api/plugins`
|
|
||||||
// can return `nav` as a plain string (manifest `"nav": "Declared"`) or an
|
|
||||||
// object with a `.label`, and _pluginNav() may synthesize an object (e.g. the
|
|
||||||
// Capability Inspector). Handle all three so string labels and the synthesized
|
|
||||||
// label aren't dropped in favour of the plugin name.
|
|
||||||
function _navLabel(nav, plugin) {
|
|
||||||
if (typeof nav === 'string' && nav.trim()) return nav;
|
|
||||||
if (nav && typeof nav === 'object' && nav.label) return nav.label;
|
|
||||||
return (plugin && (plugin.name || plugin.id)) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _pluginNav(plugin) {
|
|
||||||
if (!plugin || !plugin.id) return null;
|
|
||||||
if (plugin.id === 'capability_inspector') {
|
|
||||||
if (!_capabilityInspectorNavEnabled()) return null;
|
|
||||||
return plugin.nav || { label: 'Capabilities', screen: 'plugin-capability_inspector' };
|
|
||||||
}
|
|
||||||
return plugin.nav || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _commandUiDomain(domain, command, plugin, payload) {
|
|
||||||
try {
|
|
||||||
if (!window.feedBack?.capabilities?.command) return;
|
|
||||||
await window.feedBack.capabilities.command(domain, command, {
|
|
||||||
requester: plugin.id || 'plugin',
|
|
||||||
target: { id: payload.id, pluginId: plugin.id, region: payload.region },
|
|
||||||
payload: { ...payload, pluginId: plugin.id },
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`ui contribution ${command} failed for ${plugin.id}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _registerLegacyPluginUiContributions(plugin) {
|
|
||||||
const previous = _pluginUiContributions.get(plugin.id) || [];
|
|
||||||
for (const contribution of previous) {
|
|
||||||
await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution);
|
|
||||||
}
|
|
||||||
const contributions = [];
|
|
||||||
const nav = _pluginNav(plugin);
|
|
||||||
if (nav) {
|
|
||||||
contributions.push({ domain: 'ui.navigation', id: `${plugin.id}:nav`, region: 'plugins', label: _navLabel(nav, plugin), mounted: true });
|
|
||||||
}
|
|
||||||
if (plugin.has_screen) {
|
|
||||||
contributions.push({ domain: 'ui.plugin-screens', id: `${plugin.id}:screen`, region: 'plugin-screens', label: plugin.name || plugin.id, mounted: true });
|
|
||||||
}
|
|
||||||
if (plugin.has_settings) {
|
|
||||||
contributions.push({ domain: 'settings', id: `${plugin.id}:settings`, region: 'plugin-settings', label: plugin.name || plugin.id, mounted: true });
|
|
||||||
}
|
|
||||||
if (plugin.type === 'visualization') {
|
|
||||||
contributions.push({ domain: 'ui.player-overlays', id: `${plugin.id}:visualization`, region: 'visualization-picker', label: plugin.name || plugin.id, mounted: true });
|
|
||||||
}
|
|
||||||
contributions.sort((a, b) => `${a.domain}:${a.id}`.localeCompare(`${b.domain}:${b.id}`));
|
|
||||||
_pluginUiContributions.set(plugin.id, contributions);
|
|
||||||
for (const contribution of contributions) {
|
|
||||||
await _commandUiDomain(contribution.domain, 'register-contribution', plugin, contribution);
|
|
||||||
await _commandUiDomain(contribution.domain, 'mount', plugin, contribution);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings-tab containers that can host plugin <details> panels on the v3
|
|
||||||
// tabbed settings page. '#plugin-settings' is the fallback bucket (and the
|
|
||||||
// only container in the classic v2 settings page); the per-tab containers map
|
|
||||||
// to a plugin manifest's settings.category. A plugin with no category, or one
|
|
||||||
// whose tab container is absent (v2, or render not yet run), falls back to
|
|
||||||
// '#plugin-settings'. Body divs injected per plugin use id
|
|
||||||
// `plugin-settings-<pluginId>` and live INSIDE a <details>, so they are never
|
|
||||||
// direct children of these containers — no id collision in the scans below.
|
|
||||||
const _PLUGIN_SETTINGS_CONTAINER_IDS = [
|
|
||||||
'plugin-settings', 'plugin-settings-graphics',
|
|
||||||
'plugin-settings-mic', 'plugin-settings-progression',
|
|
||||||
];
|
|
||||||
function _pluginSettingsContainers() {
|
|
||||||
const out = [];
|
|
||||||
for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) out.push(el);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function _pluginSettingsTarget(plugin) {
|
|
||||||
const cat = plugin && plugin.settings_category;
|
|
||||||
if (cat) {
|
|
||||||
const el = document.getElementById('plugin-settings-' + cat);
|
|
||||||
if (el) return el;
|
|
||||||
}
|
|
||||||
return document.getElementById('plugin-settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPlugins() {
|
|
||||||
if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; }
|
|
||||||
_loadPluginsInFlight = true;
|
|
||||||
console.log('[feedBack] loadPlugins: start');
|
|
||||||
let plugins;
|
|
||||||
const navContainer = document.getElementById('nav-plugins');
|
|
||||||
const mobileNavContainer = document.getElementById('mobile-nav-plugins');
|
|
||||||
// Snapshot current nav so we can restore it if the fetch fails.
|
|
||||||
const _savedNav = navContainer ? navContainer.innerHTML : null;
|
|
||||||
const _savedMobileNav = mobileNavContainer ? mobileNavContainer.innerHTML : null;
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/plugins');
|
|
||||||
const fetchedPlugins = await resp.json();
|
|
||||||
const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')));
|
|
||||||
plugins = fetchedPlugins.slice().sort((a, b) => {
|
|
||||||
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
|
||||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
|
||||||
});
|
|
||||||
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
|
||||||
// from this response. Absent ≠ uninstalled: the backend clears its
|
|
||||||
// plugin registry at the start of load_plugins() and repopulates it
|
|
||||||
// incrementally while HTTP stays up, so every backend restart serves a
|
|
||||||
// window of partial (even empty) responses. The old sweep unmounted UI
|
|
||||||
// contributions and unregistered capability participants on mere
|
|
||||||
// absence, permanently breaking still-loaded plugins — their scripts
|
|
||||||
// don't re-run (loadedScripts guard below), so nothing ever
|
|
||||||
// re-registered. A genuine mid-session uninstall now leaves the
|
|
||||||
// (already-evaluated, un-unloadable) script's contributions in place
|
|
||||||
// until reload; its nav entry still disappears because nav is rebuilt
|
|
||||||
// from the response each round. Same invariant as the settings/screen
|
|
||||||
// DOM wipe and _reconcilePluginStyles below.
|
|
||||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const capabilityApi = window.feedBack?.capabilities;
|
|
||||||
if (capabilityApi?.registerParticipants) {
|
|
||||||
capabilityApi.registerParticipants(capabilityPlugins);
|
|
||||||
if (capabilityApi.registerCompatibilityShim) {
|
|
||||||
for (const plugin of capabilityPlugins) {
|
|
||||||
for (const shim of Array.isArray(plugin.compatibility_shims) ? plugin.compatibility_shims : []) {
|
|
||||||
capabilityApi.registerCompatibilityShim(shim);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
capabilityApi.validateRuntime?.({ phase: 'plugin-manifest-load' });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[feedBack] capability manifest registration failed:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plugin settings panels mount into one of several tab containers —
|
|
||||||
// see _pluginSettingsContainers()/_pluginSettingsTarget() above.
|
|
||||||
|
|
||||||
// Plugins whose screen.js has already been evaluated this session
|
|
||||||
// at the current version AND whose DOM is still in the document.
|
|
||||||
// Their listeners were bound to the existing settings / screen DOM,
|
|
||||||
// so we must preserve that DOM — the script load guard below skips
|
|
||||||
// re-evaluating screen.js, and a fresh empty DOM with no listeners
|
|
||||||
// would leave the plugin half-hydrated on subsequent loadPlugins()
|
|
||||||
// calls (e.g. the streamed refetches in _streamPluginStartup).
|
|
||||||
//
|
|
||||||
// The DOM-existence check is the safety net for plugins that
|
|
||||||
// disappeared and reappeared between calls (uninstall + reinstall,
|
|
||||||
// or a backend snapshot churn that drops a plugin then restores
|
|
||||||
// it). In that case the loadedScripts key would still be set, but
|
|
||||||
// any listeners are bound to elements that have since been removed
|
|
||||||
// — drop the stale key so screen.js re-runs against the fresh DOM
|
|
||||||
// we're about to inject.
|
|
||||||
// Map<pluginId, version> — one entry per plugin. Storing only the
|
|
||||||
// currently-loaded version (rather than a Set of all (id, version)
|
|
||||||
// pairs ever loaded) means upgrade → downgrade → upgrade cycles
|
|
||||||
// within one session don't leave stale keys that could mistakenly
|
|
||||||
// mark an old version as already-hydrated. Coerce a legacy Set, if
|
|
||||||
// present, to an empty Map — the previous shape never shipped.
|
|
||||||
let loadedScripts = window.feedBack._loadedPluginScripts;
|
|
||||||
if (!(loadedScripts instanceof Map)) {
|
|
||||||
loadedScripts = new Map();
|
|
||||||
window.feedBack._loadedPluginScripts = loadedScripts;
|
|
||||||
}
|
|
||||||
const _removePluginScriptTags = (pluginId) => {
|
|
||||||
// Filter via dataset rather than a CSS attribute selector —
|
|
||||||
// CSS.escape is not universally available, and plugin IDs
|
|
||||||
// aren't constrained server-side.
|
|
||||||
document.querySelectorAll('script[data-plugin-id]').forEach((s) => {
|
|
||||||
if (s.dataset.pluginId === pluginId) s.remove();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// Mirror of loadedScripts for the plugin `styles` capability: a single
|
|
||||||
// versioned <link rel=stylesheet> per plugin lives in <head>, deduped by
|
|
||||||
// id → version so an upgrade swaps it and re-activation doesn't pile up
|
|
||||||
// duplicate tags. The <link> covers both the plugin's screen and its
|
|
||||||
// settings panel. Plugins ship preflight-off (utilities only) CSS, so a
|
|
||||||
// stylesheet that lingers after deactivation can't bleed a base reset.
|
|
||||||
let loadedStyles = window.feedBack._loadedPluginStyles;
|
|
||||||
if (!(loadedStyles instanceof Map)) {
|
|
||||||
loadedStyles = new Map();
|
|
||||||
window.feedBack._loadedPluginStyles = loadedStyles;
|
|
||||||
}
|
|
||||||
const _removePluginStyleTags = (pluginId) => {
|
|
||||||
// Same dataset-filter rationale as _removePluginScriptTags.
|
|
||||||
document.querySelectorAll('link[data-plugin-id]').forEach((l) => {
|
|
||||||
if (l.dataset.pluginId === pluginId) l.remove();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const _injectPluginStyles = (plugin) => {
|
|
||||||
// Tear down a <link> we injected earlier this session when the plugin
|
|
||||||
// no longer ships a usable stylesheet — upgraded to drop `styles`, or
|
|
||||||
// to an invalid path — so stale CSS can't keep applying after the
|
|
||||||
// plugin disabled its styling.
|
|
||||||
const teardownStale = () => {
|
|
||||||
if (loadedStyles.has(plugin.id)) {
|
|
||||||
_removePluginStyleTags(plugin.id);
|
|
||||||
loadedStyles.delete(plugin.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (!plugin.has_styles || !plugin.styles) { teardownStale(); return; }
|
|
||||||
// `styles` is a plugin-root-relative path (like screen/script/routes)
|
|
||||||
// and must live under assets/ so it serves through the sandboxed
|
|
||||||
// asset route — e.g. "assets/plugin.css". Reject anything that can't
|
|
||||||
// reach a served file or would build a malformed URL: not under
|
|
||||||
// assets/, a `..` traversal segment, a backslash, or a `?`/`#` that
|
|
||||||
// would collide with the cache-busting query we append. The server
|
|
||||||
// also enforces containment via safe_join — this just avoids the
|
|
||||||
// wasted 404 and matches the documented contract.
|
|
||||||
const path = String(plugin.styles).replace(/^\/+/, '');
|
|
||||||
const unsafe = !path.startsWith('assets/')
|
|
||||||
|| /(^|\/)\.\.(\/|$)/.test(path)
|
|
||||||
|| /[\\?#]/.test(path);
|
|
||||||
if (unsafe) {
|
|
||||||
console.warn(`Plugin ${plugin.id}: styles must be a path under assets/ with no "..", backslash, or query/fragment (got "${plugin.styles}") — skipping`);
|
|
||||||
teardownStale();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const wantedVersion = plugin.version || '';
|
|
||||||
// Idempotent: same id+version already injected → nothing to do.
|
|
||||||
if (loadedStyles.get(plugin.id) === wantedVersion) return;
|
|
||||||
// A different version (or none) was loaded — drop the prior <link>
|
|
||||||
// so we never accumulate stale stylesheets across upgrades.
|
|
||||||
_removePluginStyleTags(plugin.id);
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.dataset.pluginId = plugin.id;
|
|
||||||
link.dataset.pluginVersion = wantedVersion;
|
|
||||||
// Version in the URL (the plugin `version`, mirroring the screen.js
|
|
||||||
// loader's ?v= convention) so a plugin upgrade within one session
|
|
||||||
// fetches fresh CSS instead of a copy cached by path alone.
|
|
||||||
const v = encodeURIComponent(wantedVersion);
|
|
||||||
link.href = `/api/plugins/${plugin.id}/${path}${v ? `?v=${v}` : ''}`;
|
|
||||||
// Cascade ordering: insert this <link> BEFORE core's prebuilt
|
|
||||||
// Tailwind (/static/tailwind.min.css) instead of appending at the
|
|
||||||
// end of <head>. A plugin that ships a full utility build — the
|
|
||||||
// default output of running the Tailwind CLI without a scoped
|
|
||||||
// content config — re-defines core utilities like .grid /
|
|
||||||
// .xl:grid-cols-4; appended last, those equal-specificity rules
|
|
||||||
// would win on source order and clobber core's responsive layout
|
|
||||||
// (e.g. the library grid collapses to 2 columns, the nav bar
|
|
||||||
// breaks). Loading the plugin sheet first means core wins any
|
|
||||||
// EQUAL-specificity collision, while the plugin's own namespaced
|
|
||||||
// classes still apply. A plugin can still deliberately override core
|
|
||||||
// via higher-specificity selectors or !important — this only removes
|
|
||||||
// the accidental source-order clobber.
|
|
||||||
const coreSheet =
|
|
||||||
document.head.querySelector('link[rel="stylesheet"][href*="tailwind.min.css"]')
|
|
||||||
|| document.head.querySelector('link[rel="stylesheet"]');
|
|
||||||
if (coreSheet) {
|
|
||||||
document.head.insertBefore(link, coreSheet);
|
|
||||||
} else {
|
|
||||||
document.head.appendChild(link);
|
|
||||||
}
|
|
||||||
loadedStyles.set(plugin.id, wantedVersion);
|
|
||||||
};
|
|
||||||
const _reconcilePluginStyles = (currentPlugins) => {
|
|
||||||
// Drop stylesheets for plugins the response KNOWS about but that
|
|
||||||
// are no longer ready+styled this round. _injectPluginStyles below
|
|
||||||
// only visits plugins still returned by the API, so a newly-not-
|
|
||||||
// ready or unstyled plugin would otherwise keep its <link>
|
|
||||||
// applying. Plugins merely ABSENT from the response keep their
|
|
||||||
// stylesheet — a transient partial response during a backend
|
|
||||||
// restart is not an uninstall (same invariant as the screen/
|
|
||||||
// settings wipe below), and stripping the <link> would leave a
|
|
||||||
// still-loaded plugin visible but unstyled.
|
|
||||||
const responded = new Set(currentPlugins.map((p) => p.id));
|
|
||||||
const styled = new Set(
|
|
||||||
currentPlugins
|
|
||||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
|
||||||
.map((p) => p.id),
|
|
||||||
);
|
|
||||||
for (const id of Array.from(loadedStyles.keys())) {
|
|
||||||
if (responded.has(id) && !styled.has(id)) {
|
|
||||||
_removePluginStyleTags(id);
|
|
||||||
loadedStyles.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const existingSettingsByPluginId = new Map();
|
|
||||||
for (const container of _pluginSettingsContainers()) {
|
|
||||||
for (const child of container.children) {
|
|
||||||
const pid = child.dataset ? child.dataset.pluginId : null;
|
|
||||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Plugins named in THIS response. A plugin can be transiently absent
|
|
||||||
// from /api/plugins — the backend clears its registry at the start of
|
|
||||||
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
|
||||||
// so every backend restart serves a window of partial (even empty)
|
|
||||||
// responses. The wipe loops below must never treat that absence as an
|
|
||||||
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
|
||||||
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
|
||||||
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
|
||||||
// audio_engine's native signal chain (its init re-ran against the
|
|
||||||
// surviving engine chain). Absent plugins keep their DOM and script;
|
|
||||||
// they're re-reconciled when they reappear in a later response.
|
|
||||||
const respondedIds = new Set(plugins.map((p) => p.id));
|
|
||||||
const alreadyHydrated = new Set();
|
|
||||||
for (const p of plugins) {
|
|
||||||
if (!p.has_script) continue;
|
|
||||||
// Version must match exactly — an upgrade / downgrade has to
|
|
||||||
// re-run the new script against fresh DOM.
|
|
||||||
if (loadedScripts.get(p.id) !== (p.version || '')) continue;
|
|
||||||
const screenOk = !p.has_screen || !!document.getElementById(`plugin-${p.id}`);
|
|
||||||
const settingsOk = !p.has_settings || existingSettingsByPluginId.has(p.id);
|
|
||||||
if (screenOk && settingsOk) {
|
|
||||||
alreadyHydrated.add(p.id);
|
|
||||||
} else {
|
|
||||||
// DOM was wiped externally (uninstall + reinstall, snapshot
|
|
||||||
// churn) — drop the entry and remove the orphaned <script>
|
|
||||||
// so screen.js re-runs against fresh DOM below.
|
|
||||||
loadedScripts.delete(p.id);
|
|
||||||
_removePluginScriptTags(p.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear plugin-owned containers, but keep already-hydrated plugins'
|
|
||||||
// settings / screen DOM. Nav links carry no per-plugin script state,
|
|
||||||
// so always rebuild them.
|
|
||||||
navContainer.innerHTML = '';
|
|
||||||
mobileNavContainer.innerHTML = '<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>';
|
|
||||||
for (const container of _pluginSettingsContainers()) {
|
|
||||||
[...container.children].forEach((el) => {
|
|
||||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
|
||||||
// Remove junk (no plugin id) and plugins the response KNOWS
|
|
||||||
// about but that failed hydration; leave plugins absent from
|
|
||||||
// the response untouched (see respondedIds above).
|
|
||||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
|
||||||
// dataset.pluginId is the source of truth (set on injection);
|
|
||||||
// the id-prefix fallback covers screens injected before this
|
|
||||||
// change shipped — both forms strip a single leading "plugin-".
|
|
||||||
const pid = (el.dataset && el.dataset.pluginId)
|
|
||||||
|| el.id.replace(/^plugin-/, '');
|
|
||||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
|
||||||
// collapsibles. Reveal it whenever any plugins are installed —
|
|
||||||
// updates are relevant even for plugins that contribute no settings.
|
|
||||||
if (plugins.length > 0) {
|
|
||||||
const area = document.getElementById('plugin-settings-area');
|
|
||||||
if (area) area.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build plugin dropdown for desktop nav
|
|
||||||
const navPlugins = plugins.map(plugin => ({ plugin, nav: _pluginNav(plugin) })).filter(entry => entry.nav);
|
|
||||||
if (navPlugins.length > 0) {
|
|
||||||
const dropdown = document.createElement('div');
|
|
||||||
dropdown.className = 'relative';
|
|
||||||
dropdown.innerHTML = `
|
|
||||||
<button class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
|
||||||
Plugins
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="hidden absolute top-full left-0 mt-2 bg-dark-800 border border-gray-700 rounded-xl shadow-xl py-2 min-w-[180px] max-h-[80vh] overflow-y-auto z-50" id="plugin-dropdown"></div>`;
|
|
||||||
navContainer.appendChild(dropdown);
|
|
||||||
const ddMenu = dropdown.querySelector('#plugin-dropdown');
|
|
||||||
|
|
||||||
// Close the plugin dropdown when clicking outside it. Bind ONCE:
|
|
||||||
// loadPlugins() re-runs on every plugin status change during
|
|
||||||
// startup (SSE-driven refetches), and each run rebuilds `dropdown`
|
|
||||||
// / `ddMenu`. A per-run addEventListener would leak a new global
|
|
||||||
// click listener on every refetch, each closing over a now-detached
|
|
||||||
// dropdown. The one-time handler instead resolves the LIVE dropdown
|
|
||||||
// from the DOM at click time, so it always targets the current one.
|
|
||||||
if (!window.feedBack._pluginDropdownOutsideClickBound) {
|
|
||||||
window.feedBack._pluginDropdownOutsideClickBound = true;
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
const menu = document.getElementById('plugin-dropdown');
|
|
||||||
if (!menu) return;
|
|
||||||
const container = menu.parentElement;
|
|
||||||
if (container && !container.contains(e.target)) menu.classList.add('hidden');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { plugin, nav } of navPlugins) {
|
|
||||||
const screenId = `plugin-${plugin.id}`;
|
|
||||||
// A plugin is navigable only once it's ready. While its deps
|
|
||||||
// install (status "installing") or after a failed load
|
|
||||||
// (status "failed") we still render the nav slot — disabled,
|
|
||||||
// with an "installing…" suffix or the error as a tooltip — so
|
|
||||||
// the nav is stable and the user sees the plugin is coming
|
|
||||||
// (#421). Entries without a status (legacy / stub) are ready.
|
|
||||||
const status = plugin.status || 'ready';
|
|
||||||
const isReady = status === 'ready';
|
|
||||||
// nav is truthy here (navPlugins is filtered on entry.nav), and
|
|
||||||
// is the computed value from _pluginNav() — which may be a
|
|
||||||
// string, an object that omits `label`, or a synthesized object
|
|
||||||
// (e.g. the Capability Inspector). _navLabel() normalizes all
|
|
||||||
// three and falls back to name/id so a missing label never
|
|
||||||
// renders "undefined" or throws. Use the loop's `nav`, not the
|
|
||||||
// raw `plugin.nav`, so string and synthesized labels survive.
|
|
||||||
const label = _navLabel(nav, plugin);
|
|
||||||
|
|
||||||
const item = document.createElement('a');
|
|
||||||
item.href = '#';
|
|
||||||
ddMenu.appendChild(item);
|
|
||||||
// Mobile nav — flat list
|
|
||||||
const ma = document.createElement('a');
|
|
||||||
ma.href = '#';
|
|
||||||
mobileNavContainer.appendChild(ma);
|
|
||||||
|
|
||||||
if (isReady) {
|
|
||||||
item.className = 'block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-dark-700 transition';
|
|
||||||
item.textContent = label;
|
|
||||||
item.onclick = (e) => { e.preventDefault(); ddMenu.classList.add('hidden'); showScreen(screenId); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
|
||||||
ma.className = 'text-gray-400 hover:text-white pl-4 text-sm';
|
|
||||||
ma.textContent = label;
|
|
||||||
ma.onclick = (e) => { e.preventDefault(); showScreen(screenId); ma.closest('#mobile-menu').classList.add('hidden'); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
|
||||||
} else {
|
|
||||||
const installing = status === 'installing';
|
|
||||||
const suffix = installing ? ' (installing…)' : ' (failed)';
|
|
||||||
const tip = installing
|
|
||||||
? 'This plugin is installing its dependencies and will become available shortly.'
|
|
||||||
: (plugin.error || 'This plugin failed to load. Check the server startup log for details.');
|
|
||||||
// Disabled appearance: dimmed, default cursor, no nav handler.
|
|
||||||
const cls = 'block px-4 py-2 text-sm text-gray-600 cursor-default select-none'
|
|
||||||
+ (installing ? ' animate-pulse' : '');
|
|
||||||
item.className = cls;
|
|
||||||
item.setAttribute('aria-disabled', 'true');
|
|
||||||
item.title = tip;
|
|
||||||
item.textContent = label + suffix;
|
|
||||||
// Drop disabled entries out of the tab order and strip the
|
|
||||||
// href so keyboard/screen-reader users don't land on a
|
|
||||||
// non-actionable "link" (a11y). Swallow clicks too, in case
|
|
||||||
// it's still reached via mouse.
|
|
||||||
item.removeAttribute('href');
|
|
||||||
item.setAttribute('tabindex', '-1');
|
|
||||||
item.onclick = (e) => { e.preventDefault(); };
|
|
||||||
ma.className = 'pl-4 text-sm text-gray-600 cursor-default select-none' + (installing ? ' animate-pulse' : '');
|
|
||||||
ma.setAttribute('aria-disabled', 'true');
|
|
||||||
ma.title = tip;
|
|
||||||
ma.textContent = label + suffix;
|
|
||||||
ma.removeAttribute('href');
|
|
||||||
ma.setAttribute('tabindex', '-1');
|
|
||||||
ma.onclick = (e) => { e.preventDefault(); };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tear down stylesheets for plugins that are gone / no longer styled
|
|
||||||
// before (re)injecting for the current set.
|
|
||||||
_reconcilePluginStyles(plugins);
|
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
|
||||||
try {
|
|
||||||
// Only ready plugins have their assets available (the backend
|
|
||||||
// guards screen.html/screen.js/settings.html on status=="ready").
|
|
||||||
// Installing/failed plugins contribute only the disabled nav slot
|
|
||||||
// built above — skip screen/settings/script injection for them.
|
|
||||||
if (plugin.status && plugin.status !== 'ready') continue;
|
|
||||||
await _registerLegacyPluginUiContributions(plugin);
|
|
||||||
const screenId = `plugin-${plugin.id}`;
|
|
||||||
|
|
||||||
// Inject the plugin's stylesheet FIRST (before screen HTML/JS) so
|
|
||||||
// its utilities are present on first paint. Idempotent + version-
|
|
||||||
// deduped, so it's safe to call for already-hydrated plugins too.
|
|
||||||
_injectPluginStyles(plugin);
|
|
||||||
|
|
||||||
// Inject screen container. Skip for already-hydrated plugins —
|
|
||||||
// their existing screen DOM still has the listeners that
|
|
||||||
// screen.js bound on first load (rebuilding here would orphan
|
|
||||||
// them, since the script load guard further down won't re-run
|
|
||||||
// screen.js to re-bind).
|
|
||||||
if (plugin.has_screen && !alreadyHydrated.has(plugin.id)) {
|
|
||||||
const screenDiv = document.createElement('div');
|
|
||||||
screenDiv.id = screenId;
|
|
||||||
screenDiv.className = 'screen';
|
|
||||||
screenDiv.dataset.pluginId = plugin.id;
|
|
||||||
screenDiv.dataset.pluginVersion = plugin.version || '';
|
|
||||||
// Insert before the player screen
|
|
||||||
const player = document.getElementById('player');
|
|
||||||
player.parentNode.insertBefore(screenDiv, player);
|
|
||||||
|
|
||||||
const htmlResp = await fetch(`/api/plugins/${plugin.id}/screen.html`);
|
|
||||||
screenDiv.innerHTML = await htmlResp.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject settings section — wrapped in a collapsible <details>
|
|
||||||
// per plugin so the page stays scannable as plugins accumulate.
|
|
||||||
// Collapsed by default; <details>/<summary> handles state natively.
|
|
||||||
// Skip for already-hydrated plugins — preserved details element
|
|
||||||
// still carries listeners wired by its inline settings script
|
|
||||||
// and by screen.js on first load.
|
|
||||||
// Resolve which settings tab this plugin's panel mounts under
|
|
||||||
// (manifest settings.category), falling back to '#plugin-settings'.
|
|
||||||
const settingsTarget = plugin.has_settings ? _pluginSettingsTarget(plugin) : null;
|
|
||||||
if (plugin.has_settings && settingsTarget && !alreadyHydrated.has(plugin.id)) {
|
|
||||||
const details = document.createElement('details');
|
|
||||||
details.className = 'bg-dark-700/40 border border-gray-800 rounded-xl overflow-hidden group';
|
|
||||||
details.dataset.pluginId = plugin.id;
|
|
||||||
details.dataset.pluginVersion = plugin.version || '';
|
|
||||||
|
|
||||||
const summary = document.createElement('summary');
|
|
||||||
// .plugin-settings-summary class hides the browser's native
|
|
||||||
// disclosure triangle (see style.css) so only our chevron shows.
|
|
||||||
// flex-col allows the fallback explanation note to appear below
|
|
||||||
// the name/badges row when plugin.fallback is set.
|
|
||||||
summary.className = 'plugin-settings-summary cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-300 hover:bg-dark-700/70 transition flex flex-col';
|
|
||||||
// Inner row: plugin name/badges (left) + chevron (right).
|
|
||||||
const headerRow = document.createElement('span');
|
|
||||||
headerRow.className = 'flex items-center justify-between';
|
|
||||||
const labelWrap = document.createElement('span');
|
|
||||||
labelWrap.className = 'flex items-center gap-2';
|
|
||||||
const labelSpan = document.createElement('span');
|
|
||||||
labelSpan.textContent = plugin.name || plugin.id;
|
|
||||||
labelWrap.appendChild(labelSpan);
|
|
||||||
// "Bundled" marker (feedBack#160). Visually distinguishes
|
|
||||||
// plugins that ship with the default container image from
|
|
||||||
// user-installed ones so users don't try to remove a core
|
|
||||||
// plugin via the manage-plugin flow and brick a feature
|
|
||||||
// that's expected to "just work".
|
|
||||||
if (plugin.bundled) {
|
|
||||||
const bundledDesc = 'This plugin ships with FeedBack core and is expected to be present.';
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-purple-400/30 bg-purple-500/10 text-purple-300';
|
|
||||||
badge.title = bundledDesc;
|
|
||||||
badge.setAttribute('aria-label', 'Bundled — ' + bundledDesc);
|
|
||||||
badge.setAttribute('role', 'img');
|
|
||||||
badge.innerHTML = `
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3zM6 11h12a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2v-6a2 2 0 012-2z"/>
|
|
||||||
</svg>
|
|
||||||
Bundled
|
|
||||||
`;
|
|
||||||
labelWrap.appendChild(badge);
|
|
||||||
}
|
|
||||||
// "Fallback" warning badge: the bundled copy failed to load its
|
|
||||||
// routes, so the server fell back to this older user-installed
|
|
||||||
// copy. Warn users so they know the bundled build is broken and
|
|
||||||
// can check the server startup log for the root cause.
|
|
||||||
if (plugin.fallback) {
|
|
||||||
const fbBadge = document.createElement('span');
|
|
||||||
fbBadge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-yellow-400/40 bg-yellow-500/10 text-yellow-300';
|
|
||||||
fbBadge.setAttribute('aria-hidden', 'true');
|
|
||||||
fbBadge.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg> Fallback';
|
|
||||||
labelWrap.appendChild(fbBadge);
|
|
||||||
}
|
|
||||||
// Assemble inner header row: [name/badges (left)] [chevron (right)].
|
|
||||||
// Both are placed in headerRow so the fallback note (if any)
|
|
||||||
// can sit below the entire row as a second flex-col child of
|
|
||||||
// summary, rather than being squeezed inline beside the chevron.
|
|
||||||
headerRow.appendChild(labelWrap);
|
|
||||||
// Chevron icon — built via setAttributeNS so the SVG sits in
|
|
||||||
// the SVG namespace and renders correctly. Plugin label is
|
|
||||||
// appended as text above so manifest values can't inject HTML.
|
|
||||||
const svgNS = 'http://www.w3.org/2000/svg';
|
|
||||||
const svg = document.createElementNS(svgNS, 'svg');
|
|
||||||
svg.setAttribute('class', 'w-4 h-4 text-gray-500 transition-transform group-open:rotate-180');
|
|
||||||
svg.setAttribute('fill', 'none');
|
|
||||||
svg.setAttribute('stroke', 'currentColor');
|
|
||||||
svg.setAttribute('viewBox', '0 0 24 24');
|
|
||||||
const svgPath = document.createElementNS(svgNS, 'path');
|
|
||||||
svgPath.setAttribute('stroke-linecap', 'round');
|
|
||||||
svgPath.setAttribute('stroke-linejoin', 'round');
|
|
||||||
svgPath.setAttribute('stroke-width', '2');
|
|
||||||
svgPath.setAttribute('d', 'M19 9l-7 7-7-7');
|
|
||||||
svg.appendChild(svgPath);
|
|
||||||
headerRow.appendChild(svg);
|
|
||||||
summary.appendChild(headerRow);
|
|
||||||
// Fallback explanation note: a visible <p> below the header row,
|
|
||||||
// accessible to touch/keyboard users (browser tooltip via title/
|
|
||||||
// aria-label alone is hover-only and insufficient). Appended to
|
|
||||||
// summary (not labelWrap) so it renders as the second child in
|
|
||||||
// summary's flex-col layout, appearing below the name+badges row.
|
|
||||||
if (plugin.fallback) {
|
|
||||||
const fbNote = document.createElement('span');
|
|
||||||
fbNote.className = 'block text-xs text-yellow-300/80 mt-1';
|
|
||||||
fbNote.textContent = 'The bundled version failed to start. This user-installed copy is serving as a fallback. Check the server startup log for details.';
|
|
||||||
summary.appendChild(fbNote);
|
|
||||||
}
|
|
||||||
details.appendChild(summary);
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.id = `plugin-settings-${plugin.id}`;
|
|
||||||
body.className = 'px-4 py-4 border-t border-gray-800 space-y-4';
|
|
||||||
details.appendChild(body);
|
|
||||||
|
|
||||||
settingsTarget.appendChild(details);
|
|
||||||
|
|
||||||
const settingsResp = await fetch(`/api/plugins/${plugin.id}/settings.html`);
|
|
||||||
body.innerHTML = await settingsResp.text();
|
|
||||||
// <script> tags inserted via innerHTML are intentionally
|
|
||||||
// inert per the HTML5 spec — the browser parses them as
|
|
||||||
// DOM nodes but never runs the body. That silently breaks
|
|
||||||
// any plugin settings.html that wires event handlers via
|
|
||||||
// addEventListener (e.g. file pickers, anything that
|
|
||||||
// can't be expressed as an inline onclick=… attribute),
|
|
||||||
// and any inline IIFE that hydrates form values from
|
|
||||||
// localStorage. Re-create each script node — script
|
|
||||||
// elements created via document.createElement DO execute
|
|
||||||
// when appended — so plugins get the script behavior
|
|
||||||
// they'd expect from a normal HTML document.
|
|
||||||
body.querySelectorAll('script').forEach(oldScript => {
|
|
||||||
const newScript = document.createElement('script');
|
|
||||||
for (const attr of oldScript.attributes) {
|
|
||||||
newScript.setAttribute(attr.name, attr.value);
|
|
||||||
}
|
|
||||||
newScript.textContent = oldScript.textContent;
|
|
||||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load plugin JS
|
|
||||||
if (plugin.has_script) {
|
|
||||||
const wantedVersion = plugin.version || '';
|
|
||||||
if (loadedScripts.get(plugin.id) !== wantedVersion) {
|
|
||||||
// A different version (or none) was loaded previously —
|
|
||||||
// remove the prior <script> tag for this plugin id so we
|
|
||||||
// don't accumulate stale versions on upgrade/downgrade.
|
|
||||||
_removePluginScriptTags(plugin.id);
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
// Include version in URL so a plugin upgrade within the
|
|
||||||
// same browser session fetches the new screen.js instead
|
|
||||||
// of a cached copy keyed only by path (matches the art
|
|
||||||
// URL ?v=mtime convention elsewhere in this file).
|
|
||||||
const v = encodeURIComponent(wantedVersion);
|
|
||||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
|
||||||
// Module-migration (R0): a migrated plugin declares
|
|
||||||
// scriptType:"module" and its screen.js is `import
|
|
||||||
// './src/main.js'`. A <script type="module"> fires load
|
|
||||||
// only after its whole static-import graph evaluates, so
|
|
||||||
// the await-onload completion + _loadingPluginId contract
|
|
||||||
// below is preserved (a classic-IIFE dynamic import()
|
|
||||||
// would not). Classic plugins are unaffected.
|
|
||||||
if (plugin.script_type === 'module') script.type = 'module';
|
|
||||||
script.dataset.pluginId = plugin.id;
|
|
||||||
script.dataset.pluginVersion = wantedVersion;
|
|
||||||
window.feedBack._loadingPluginId = plugin.id;
|
|
||||||
script.onload = () => {
|
|
||||||
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
|
||||||
loadedScripts.set(plugin.id, wantedVersion);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
script.onerror = (err) => {
|
|
||||||
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
|
||||||
loadedScripts.delete(plugin.id);
|
|
||||||
reject(err);
|
|
||||||
};
|
|
||||||
document.body.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Plugin '${plugin.id}' failed to load, skipping:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load plugins:', e);
|
|
||||||
// Restore nav so a failed re-hydration call doesn't leave it blank.
|
|
||||||
if (_savedNav !== null && navContainer) navContainer.innerHTML = _savedNav;
|
|
||||||
if (_savedMobileNav !== null && mobileNavContainer) mobileNavContainer.innerHTML = _savedMobileNav;
|
|
||||||
_loadPluginsInFlight = false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
_loadPluginsInFlight = false;
|
|
||||||
return plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-run loadPlugins (and the viz picker, since a newly-ready plugin may
|
|
||||||
// register a window.feedBackViz_<id> factory) when plugin status changes.
|
|
||||||
// Debounced so a burst of plugin-registered/plugin-error events during
|
|
||||||
// startup collapses into a single refetch.
|
|
||||||
let _pluginRefreshTimer = null;
|
|
||||||
function _refreshPluginsSoon() {
|
|
||||||
clearTimeout(_pluginRefreshTimer);
|
|
||||||
_pluginRefreshTimer = setTimeout(async () => {
|
|
||||||
const plugins = await loadPlugins();
|
|
||||||
if (plugins) {
|
|
||||||
_populateVizPicker(plugins);
|
|
||||||
} else {
|
|
||||||
// loadPlugins() returned null because a refetch was already in
|
|
||||||
// flight, so this status change would otherwise be dropped. Re-arm
|
|
||||||
// the debounce so the newer state is still applied once the
|
|
||||||
// in-flight load finishes. Reuses the 250ms delay (and the
|
|
||||||
// in-flight guard clears quickly), so this can't tight-loop.
|
|
||||||
_refreshPluginsSoon();
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _pluginStreamStarted = false;
|
|
||||||
function _streamPluginStartup() {
|
|
||||||
// Watch the SAME /api/startup-status/stream the splash used to gate on.
|
|
||||||
// Instead of blocking, we let the nav render immediately (loadPlugins ran
|
|
||||||
// already) and refetch whenever a plugin graduates to ready or fails — so
|
|
||||||
// its nav slot flips from "installing…" to active/failed without a reload
|
|
||||||
// (#421). loadPlugins is idempotent (in-flight guard + version map), so
|
|
||||||
// extra refetches are cheap and safe.
|
|
||||||
if (_pluginStreamStarted) return;
|
|
||||||
_pluginStreamStarted = true;
|
|
||||||
|
|
||||||
if (typeof EventSource === 'undefined') { _pollPluginStartup(); return; }
|
|
||||||
|
|
||||||
const es = new EventSource('/api/startup-status/stream');
|
|
||||||
es.onmessage = (event) => {
|
|
||||||
let status;
|
|
||||||
try { status = JSON.parse(event.data); } catch { return; }
|
|
||||||
if (!status || status.type === 'keepalive') return;
|
|
||||||
const phase = (status.phase || '').trim();
|
|
||||||
if (phase === 'plugin-registered' || phase === 'plugin-error') {
|
|
||||||
_refreshPluginsSoon();
|
|
||||||
}
|
|
||||||
// Terminal: one last refetch to catch anything missed, then stop.
|
|
||||||
if (!status.running && (phase === 'complete' || phase === 'error')) {
|
|
||||||
_refreshPluginsSoon();
|
|
||||||
es.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
es.onerror = () => {
|
|
||||||
// Stream dropped (proxy buffering, backend hiccup). Stop retrying the
|
|
||||||
// stream and fall back to a bounded poll so late installs still surface.
|
|
||||||
es.close();
|
|
||||||
_pollPluginStartup();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let _pollStartupStarted = false;
|
|
||||||
async function _pollPluginStartup() {
|
|
||||||
// SSE-unavailable fallback: poll /api/startup-status until the backend
|
|
||||||
// finishes its plugin loader, refetching whenever the ready count changes
|
|
||||||
// or it goes terminal. Bounded so a backend that never finishes doesn't
|
|
||||||
// poll forever.
|
|
||||||
if (_pollStartupStarted) return;
|
|
||||||
_pollStartupStarted = true;
|
|
||||||
// Generous headroom over the documented worst case (whisperx → torch et al.
|
|
||||||
// can take 20-30 min): a 30-min ceiling would stop polling right as a
|
|
||||||
// slipping install — slow mirror, pip retry — actually finishes. 60 min
|
|
||||||
// leaves margin so the late graduation still surfaces. (#421)
|
|
||||||
const DEADLINE_MS = 60 * 60 * 1000;
|
|
||||||
const start = Date.now();
|
|
||||||
// Track a composite signature, not just the ready count: a plugin can fail
|
|
||||||
// (phase → "plugin-error", current_plugin/error change) without changing
|
|
||||||
// `loaded`, e.g. the next plugin breaks after all prior ones succeeded.
|
|
||||||
// Watching only `loaded` would miss that transition until some later
|
|
||||||
// ready-count change or terminal completion, so the failed/error nav state
|
|
||||||
// wouldn't surface. Refetch whenever any of these move.
|
|
||||||
let lastSig = null;
|
|
||||||
while (Date.now() - start < DEADLINE_MS) {
|
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/startup-status');
|
|
||||||
if (!resp.ok) continue;
|
|
||||||
const status = await resp.json();
|
|
||||||
const sig = JSON.stringify([
|
|
||||||
Number(status.loaded || 0),
|
|
||||||
status.phase || '',
|
|
||||||
status.current_plugin || '',
|
|
||||||
status.error || '',
|
|
||||||
]);
|
|
||||||
if (sig !== lastSig) { lastSig = sig; _refreshPluginsSoon(); }
|
|
||||||
if (!status.running) { _refreshPluginsSoon(); return; }
|
|
||||||
} catch (_e) { /* network error — keep trying */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrapPluginsAndUi() {
|
|
||||||
// #421: never gate the nav on full plugin startup. Render it immediately
|
|
||||||
// from /api/plugins (ready plugins active; installing/failed disabled),
|
|
||||||
// then stream plugin status so each entry resolves in place as its
|
|
||||||
// dependencies finish installing or its load fails.
|
|
||||||
const plugins = await loadPlugins();
|
|
||||||
_streamPluginStartup();
|
|
||||||
return plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load library on start. loadSettings is awaited alongside so persisted
|
// Load library on start. loadSettings is awaited alongside so persisted
|
||||||
// values (A/V offset, mastery, etc.) are applied to the highway + HUD
|
// values (A/V offset, mastery, etc.) are applied to the highway + HUD
|
||||||
@@ -12101,6 +11321,9 @@ async function bootstrapPluginsAndUi() {
|
|||||||
try { _wireSpeedPresetsOnce(); } catch (e) { console.warn('_wireSpeedPresetsOnce failed:', e); }
|
try { _wireSpeedPresetsOnce(); } catch (e) { console.warn('_wireSpeedPresetsOnce failed:', e); }
|
||||||
checkScanAndLoad();
|
checkScanAndLoad();
|
||||||
|
|
||||||
|
// The loader lives in ./js/plugin-loader.js and must not import app.js
|
||||||
|
// (no-cycle), so hand it the one host function it needs.
|
||||||
|
configurePluginLoader({ populateVizPicker: _populateVizPicker });
|
||||||
const plugins = await bootstrapPluginsAndUi();
|
const plugins = await bootstrapPluginsAndUi();
|
||||||
await loadLibraryProviders({ restoreSaved: true, reloadOnChange: true });
|
await loadLibraryProviders({ restoreSaved: true, reloadOnChange: true });
|
||||||
// Viz picker depends on plugin scripts having loaded (to find
|
// Viz picker depends on plugin scripts having loaded (to find
|
||||||
|
|||||||
@@ -0,0 +1,829 @@
|
|||||||
|
// The plugin loader — the R0 host rails.
|
||||||
|
//
|
||||||
|
// Carved verbatim out of static/app.js (R3a). This is the highest-risk module in
|
||||||
|
// core: it fetches /api/plugins, injects each plugin's screen.js (as
|
||||||
|
// <script type="module"> when its manifest says scriptType:"module"), mounts nav
|
||||||
|
// entries and screens, and wires plugin capability + UI contributions. If it
|
||||||
|
// breaks, every plugin breaks — so every change here ends with a real plugin
|
||||||
|
// booted against a local uvicorn, not just a green test run.
|
||||||
|
//
|
||||||
|
// Two things it needs from app.js, and why neither is an import:
|
||||||
|
// * window.showScreen — already the public host contract (constitution II),
|
||||||
|
// so it is called through `window` rather than re-coupled as an import.
|
||||||
|
// * _populateVizPicker — app.js's viz-picker UI, which must run after plugin
|
||||||
|
// scripts have loaded (it looks for window.feedBackViz_<id> factories).
|
||||||
|
// It is injected once at boot via configurePluginLoader() instead of
|
||||||
|
// imported, so this module never imports app.js and the graph stays acyclic
|
||||||
|
// (the import-x/no-cycle gate).
|
||||||
|
|
||||||
|
// The injected host seam. Populated once, by app.js, at boot.
|
||||||
|
//
|
||||||
|
// The default is NOISY on purpose. A silent no-op stub is the classic failure
|
||||||
|
// mode for this pattern: if the wiring call in app.js is ever dropped or drifts,
|
||||||
|
// the loader keeps working, the viz picker just quietly stops refreshing, and
|
||||||
|
// nothing — no test, no boot check — says a word. Failing loudly means the
|
||||||
|
// smoke harness (which treats console errors as failures) catches it.
|
||||||
|
const _host = {
|
||||||
|
populateVizPicker: () => {
|
||||||
|
console.error(
|
||||||
|
'[plugin-loader] host seam not configured — app.js must call '
|
||||||
|
+ 'configurePluginLoader({ populateVizPicker }) before plugins load.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire the host functions this module needs. Called once from app.js at boot,
|
||||||
|
* before any plugin is loaded.
|
||||||
|
*/
|
||||||
|
export function configurePluginLoader(hooks) {
|
||||||
|
if (hooks && typeof hooks.populateVizPicker === 'function') {
|
||||||
|
_host.populateVizPicker = hooks.populateVizPicker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _loadPluginsInFlight = false;
|
||||||
|
const _pluginUiContributions = new Map();
|
||||||
|
const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu';
|
||||||
|
|
||||||
|
function _capabilityInspectorNavEnabled() {
|
||||||
|
try { return localStorage.getItem(CAPABILITY_INSPECTOR_NAV_SETTING) === '1'; }
|
||||||
|
catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a display label from a (possibly string) nav value. `/api/plugins`
|
||||||
|
// can return `nav` as a plain string (manifest `"nav": "Declared"`) or an
|
||||||
|
// object with a `.label`, and _pluginNav() may synthesize an object (e.g. the
|
||||||
|
// Capability Inspector). Handle all three so string labels and the synthesized
|
||||||
|
// label aren't dropped in favour of the plugin name.
|
||||||
|
function _navLabel(nav, plugin) {
|
||||||
|
if (typeof nav === 'string' && nav.trim()) return nav;
|
||||||
|
if (nav && typeof nav === 'object' && nav.label) return nav.label;
|
||||||
|
return (plugin && (plugin.name || plugin.id)) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pluginNav(plugin) {
|
||||||
|
if (!plugin || !plugin.id) return null;
|
||||||
|
if (plugin.id === 'capability_inspector') {
|
||||||
|
if (!_capabilityInspectorNavEnabled()) return null;
|
||||||
|
return plugin.nav || { label: 'Capabilities', screen: 'plugin-capability_inspector' };
|
||||||
|
}
|
||||||
|
return plugin.nav || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _commandUiDomain(domain, command, plugin, payload) {
|
||||||
|
try {
|
||||||
|
if (!window.feedBack?.capabilities?.command) return;
|
||||||
|
await window.feedBack.capabilities.command(domain, command, {
|
||||||
|
requester: plugin.id || 'plugin',
|
||||||
|
target: { id: payload.id, pluginId: plugin.id, region: payload.region },
|
||||||
|
payload: { ...payload, pluginId: plugin.id },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`ui contribution ${command} failed for ${plugin.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _registerLegacyPluginUiContributions(plugin) {
|
||||||
|
const previous = _pluginUiContributions.get(plugin.id) || [];
|
||||||
|
for (const contribution of previous) {
|
||||||
|
await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution);
|
||||||
|
}
|
||||||
|
const contributions = [];
|
||||||
|
const nav = _pluginNav(plugin);
|
||||||
|
if (nav) {
|
||||||
|
contributions.push({ domain: 'ui.navigation', id: `${plugin.id}:nav`, region: 'plugins', label: _navLabel(nav, plugin), mounted: true });
|
||||||
|
}
|
||||||
|
if (plugin.has_screen) {
|
||||||
|
contributions.push({ domain: 'ui.plugin-screens', id: `${plugin.id}:screen`, region: 'plugin-screens', label: plugin.name || plugin.id, mounted: true });
|
||||||
|
}
|
||||||
|
if (plugin.has_settings) {
|
||||||
|
contributions.push({ domain: 'settings', id: `${plugin.id}:settings`, region: 'plugin-settings', label: plugin.name || plugin.id, mounted: true });
|
||||||
|
}
|
||||||
|
if (plugin.type === 'visualization') {
|
||||||
|
contributions.push({ domain: 'ui.player-overlays', id: `${plugin.id}:visualization`, region: 'visualization-picker', label: plugin.name || plugin.id, mounted: true });
|
||||||
|
}
|
||||||
|
contributions.sort((a, b) => `${a.domain}:${a.id}`.localeCompare(`${b.domain}:${b.id}`));
|
||||||
|
_pluginUiContributions.set(plugin.id, contributions);
|
||||||
|
for (const contribution of contributions) {
|
||||||
|
await _commandUiDomain(contribution.domain, 'register-contribution', plugin, contribution);
|
||||||
|
await _commandUiDomain(contribution.domain, 'mount', plugin, contribution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings-tab containers that can host plugin <details> panels on the v3
|
||||||
|
// tabbed settings page. '#plugin-settings' is the fallback bucket (and the
|
||||||
|
// only container in the classic v2 settings page); the per-tab containers map
|
||||||
|
// to a plugin manifest's settings.category. A plugin with no category, or one
|
||||||
|
// whose tab container is absent (v2, or render not yet run), falls back to
|
||||||
|
// '#plugin-settings'. Body divs injected per plugin use id
|
||||||
|
// `plugin-settings-<pluginId>` and live INSIDE a <details>, so they are never
|
||||||
|
// direct children of these containers — no id collision in the scans below.
|
||||||
|
const _PLUGIN_SETTINGS_CONTAINER_IDS = [
|
||||||
|
'plugin-settings', 'plugin-settings-graphics',
|
||||||
|
'plugin-settings-mic', 'plugin-settings-progression',
|
||||||
|
];
|
||||||
|
function _pluginSettingsContainers() {
|
||||||
|
const out = [];
|
||||||
|
for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) out.push(el);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function _pluginSettingsTarget(plugin) {
|
||||||
|
const cat = plugin && plugin.settings_category;
|
||||||
|
if (cat) {
|
||||||
|
const el = document.getElementById('plugin-settings-' + cat);
|
||||||
|
if (el) return el;
|
||||||
|
}
|
||||||
|
return document.getElementById('plugin-settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlugins() {
|
||||||
|
if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; }
|
||||||
|
_loadPluginsInFlight = true;
|
||||||
|
console.log('[feedBack] loadPlugins: start');
|
||||||
|
let plugins;
|
||||||
|
const navContainer = document.getElementById('nav-plugins');
|
||||||
|
const mobileNavContainer = document.getElementById('mobile-nav-plugins');
|
||||||
|
// Snapshot current nav so we can restore it if the fetch fails.
|
||||||
|
const _savedNav = navContainer ? navContainer.innerHTML : null;
|
||||||
|
const _savedMobileNav = mobileNavContainer ? mobileNavContainer.innerHTML : null;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/plugins');
|
||||||
|
const fetchedPlugins = await resp.json();
|
||||||
|
const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')));
|
||||||
|
plugins = fetchedPlugins.slice().sort((a, b) => {
|
||||||
|
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||||
|
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
|
});
|
||||||
|
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||||
|
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||||
|
// plugin registry at the start of load_plugins() and repopulates it
|
||||||
|
// incrementally while HTTP stays up, so every backend restart serves a
|
||||||
|
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||||
|
// contributions and unregistered capability participants on mere
|
||||||
|
// absence, permanently breaking still-loaded plugins — their scripts
|
||||||
|
// don't re-run (loadedScripts guard below), so nothing ever
|
||||||
|
// re-registered. A genuine mid-session uninstall now leaves the
|
||||||
|
// (already-evaluated, un-unloadable) script's contributions in place
|
||||||
|
// until reload; its nav entry still disappears because nav is rebuilt
|
||||||
|
// from the response each round. Same invariant as the settings/screen
|
||||||
|
// DOM wipe and _reconcilePluginStyles below.
|
||||||
|
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const capabilityApi = window.feedBack?.capabilities;
|
||||||
|
if (capabilityApi?.registerParticipants) {
|
||||||
|
capabilityApi.registerParticipants(capabilityPlugins);
|
||||||
|
if (capabilityApi.registerCompatibilityShim) {
|
||||||
|
for (const plugin of capabilityPlugins) {
|
||||||
|
for (const shim of Array.isArray(plugin.compatibility_shims) ? plugin.compatibility_shims : []) {
|
||||||
|
capabilityApi.registerCompatibilityShim(shim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
capabilityApi.validateRuntime?.({ phase: 'plugin-manifest-load' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[feedBack] capability manifest registration failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin settings panels mount into one of several tab containers —
|
||||||
|
// see _pluginSettingsContainers()/_pluginSettingsTarget() above.
|
||||||
|
|
||||||
|
// Plugins whose screen.js has already been evaluated this session
|
||||||
|
// at the current version AND whose DOM is still in the document.
|
||||||
|
// Their listeners were bound to the existing settings / screen DOM,
|
||||||
|
// so we must preserve that DOM — the script load guard below skips
|
||||||
|
// re-evaluating screen.js, and a fresh empty DOM with no listeners
|
||||||
|
// would leave the plugin half-hydrated on subsequent loadPlugins()
|
||||||
|
// calls (e.g. the streamed refetches in _streamPluginStartup).
|
||||||
|
//
|
||||||
|
// The DOM-existence check is the safety net for plugins that
|
||||||
|
// disappeared and reappeared between calls (uninstall + reinstall,
|
||||||
|
// or a backend snapshot churn that drops a plugin then restores
|
||||||
|
// it). In that case the loadedScripts key would still be set, but
|
||||||
|
// any listeners are bound to elements that have since been removed
|
||||||
|
// — drop the stale key so screen.js re-runs against the fresh DOM
|
||||||
|
// we're about to inject.
|
||||||
|
// Map<pluginId, version> — one entry per plugin. Storing only the
|
||||||
|
// currently-loaded version (rather than a Set of all (id, version)
|
||||||
|
// pairs ever loaded) means upgrade → downgrade → upgrade cycles
|
||||||
|
// within one session don't leave stale keys that could mistakenly
|
||||||
|
// mark an old version as already-hydrated. Coerce a legacy Set, if
|
||||||
|
// present, to an empty Map — the previous shape never shipped.
|
||||||
|
let loadedScripts = window.feedBack._loadedPluginScripts;
|
||||||
|
if (!(loadedScripts instanceof Map)) {
|
||||||
|
loadedScripts = new Map();
|
||||||
|
window.feedBack._loadedPluginScripts = loadedScripts;
|
||||||
|
}
|
||||||
|
const _removePluginScriptTags = (pluginId) => {
|
||||||
|
// Filter via dataset rather than a CSS attribute selector —
|
||||||
|
// CSS.escape is not universally available, and plugin IDs
|
||||||
|
// aren't constrained server-side.
|
||||||
|
document.querySelectorAll('script[data-plugin-id]').forEach((s) => {
|
||||||
|
if (s.dataset.pluginId === pluginId) s.remove();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Mirror of loadedScripts for the plugin `styles` capability: a single
|
||||||
|
// versioned <link rel=stylesheet> per plugin lives in <head>, deduped by
|
||||||
|
// id → version so an upgrade swaps it and re-activation doesn't pile up
|
||||||
|
// duplicate tags. The <link> covers both the plugin's screen and its
|
||||||
|
// settings panel. Plugins ship preflight-off (utilities only) CSS, so a
|
||||||
|
// stylesheet that lingers after deactivation can't bleed a base reset.
|
||||||
|
let loadedStyles = window.feedBack._loadedPluginStyles;
|
||||||
|
if (!(loadedStyles instanceof Map)) {
|
||||||
|
loadedStyles = new Map();
|
||||||
|
window.feedBack._loadedPluginStyles = loadedStyles;
|
||||||
|
}
|
||||||
|
const _removePluginStyleTags = (pluginId) => {
|
||||||
|
// Same dataset-filter rationale as _removePluginScriptTags.
|
||||||
|
document.querySelectorAll('link[data-plugin-id]').forEach((l) => {
|
||||||
|
if (l.dataset.pluginId === pluginId) l.remove();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const _injectPluginStyles = (plugin) => {
|
||||||
|
// Tear down a <link> we injected earlier this session when the plugin
|
||||||
|
// no longer ships a usable stylesheet — upgraded to drop `styles`, or
|
||||||
|
// to an invalid path — so stale CSS can't keep applying after the
|
||||||
|
// plugin disabled its styling.
|
||||||
|
const teardownStale = () => {
|
||||||
|
if (loadedStyles.has(plugin.id)) {
|
||||||
|
_removePluginStyleTags(plugin.id);
|
||||||
|
loadedStyles.delete(plugin.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!plugin.has_styles || !plugin.styles) { teardownStale(); return; }
|
||||||
|
// `styles` is a plugin-root-relative path (like screen/script/routes)
|
||||||
|
// and must live under assets/ so it serves through the sandboxed
|
||||||
|
// asset route — e.g. "assets/plugin.css". Reject anything that can't
|
||||||
|
// reach a served file or would build a malformed URL: not under
|
||||||
|
// assets/, a `..` traversal segment, a backslash, or a `?`/`#` that
|
||||||
|
// would collide with the cache-busting query we append. The server
|
||||||
|
// also enforces containment via safe_join — this just avoids the
|
||||||
|
// wasted 404 and matches the documented contract.
|
||||||
|
const path = String(plugin.styles).replace(/^\/+/, '');
|
||||||
|
const unsafe = !path.startsWith('assets/')
|
||||||
|
|| /(^|\/)\.\.(\/|$)/.test(path)
|
||||||
|
|| /[\\?#]/.test(path);
|
||||||
|
if (unsafe) {
|
||||||
|
console.warn(`Plugin ${plugin.id}: styles must be a path under assets/ with no "..", backslash, or query/fragment (got "${plugin.styles}") — skipping`);
|
||||||
|
teardownStale();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wantedVersion = plugin.version || '';
|
||||||
|
// Idempotent: same id+version already injected → nothing to do.
|
||||||
|
if (loadedStyles.get(plugin.id) === wantedVersion) return;
|
||||||
|
// A different version (or none) was loaded — drop the prior <link>
|
||||||
|
// so we never accumulate stale stylesheets across upgrades.
|
||||||
|
_removePluginStyleTags(plugin.id);
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.dataset.pluginId = plugin.id;
|
||||||
|
link.dataset.pluginVersion = wantedVersion;
|
||||||
|
// Version in the URL (the plugin `version`, mirroring the screen.js
|
||||||
|
// loader's ?v= convention) so a plugin upgrade within one session
|
||||||
|
// fetches fresh CSS instead of a copy cached by path alone.
|
||||||
|
const v = encodeURIComponent(wantedVersion);
|
||||||
|
link.href = `/api/plugins/${plugin.id}/${path}${v ? `?v=${v}` : ''}`;
|
||||||
|
// Cascade ordering: insert this <link> BEFORE core's prebuilt
|
||||||
|
// Tailwind (/static/tailwind.min.css) instead of appending at the
|
||||||
|
// end of <head>. A plugin that ships a full utility build — the
|
||||||
|
// default output of running the Tailwind CLI without a scoped
|
||||||
|
// content config — re-defines core utilities like .grid /
|
||||||
|
// .xl:grid-cols-4; appended last, those equal-specificity rules
|
||||||
|
// would win on source order and clobber core's responsive layout
|
||||||
|
// (e.g. the library grid collapses to 2 columns, the nav bar
|
||||||
|
// breaks). Loading the plugin sheet first means core wins any
|
||||||
|
// EQUAL-specificity collision, while the plugin's own namespaced
|
||||||
|
// classes still apply. A plugin can still deliberately override core
|
||||||
|
// via higher-specificity selectors or !important — this only removes
|
||||||
|
// the accidental source-order clobber.
|
||||||
|
const coreSheet =
|
||||||
|
document.head.querySelector('link[rel="stylesheet"][href*="tailwind.min.css"]')
|
||||||
|
|| document.head.querySelector('link[rel="stylesheet"]');
|
||||||
|
if (coreSheet) {
|
||||||
|
document.head.insertBefore(link, coreSheet);
|
||||||
|
} else {
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
loadedStyles.set(plugin.id, wantedVersion);
|
||||||
|
};
|
||||||
|
const _reconcilePluginStyles = (currentPlugins) => {
|
||||||
|
// Drop stylesheets for plugins the response KNOWS about but that
|
||||||
|
// are no longer ready+styled this round. _injectPluginStyles below
|
||||||
|
// only visits plugins still returned by the API, so a newly-not-
|
||||||
|
// ready or unstyled plugin would otherwise keep its <link>
|
||||||
|
// applying. Plugins merely ABSENT from the response keep their
|
||||||
|
// stylesheet — a transient partial response during a backend
|
||||||
|
// restart is not an uninstall (same invariant as the screen/
|
||||||
|
// settings wipe below), and stripping the <link> would leave a
|
||||||
|
// still-loaded plugin visible but unstyled.
|
||||||
|
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||||
|
const styled = new Set(
|
||||||
|
currentPlugins
|
||||||
|
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||||
|
.map((p) => p.id),
|
||||||
|
);
|
||||||
|
for (const id of Array.from(loadedStyles.keys())) {
|
||||||
|
if (responded.has(id) && !styled.has(id)) {
|
||||||
|
_removePluginStyleTags(id);
|
||||||
|
loadedStyles.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const existingSettingsByPluginId = new Map();
|
||||||
|
for (const container of _pluginSettingsContainers()) {
|
||||||
|
for (const child of container.children) {
|
||||||
|
const pid = child.dataset ? child.dataset.pluginId : null;
|
||||||
|
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Plugins named in THIS response. A plugin can be transiently absent
|
||||||
|
// from /api/plugins — the backend clears its registry at the start of
|
||||||
|
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||||
|
// so every backend restart serves a window of partial (even empty)
|
||||||
|
// responses. The wipe loops below must never treat that absence as an
|
||||||
|
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||||
|
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||||
|
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||||
|
// audio_engine's native signal chain (its init re-ran against the
|
||||||
|
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||||
|
// they're re-reconciled when they reappear in a later response.
|
||||||
|
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||||
|
const alreadyHydrated = new Set();
|
||||||
|
for (const p of plugins) {
|
||||||
|
if (!p.has_script) continue;
|
||||||
|
// Version must match exactly — an upgrade / downgrade has to
|
||||||
|
// re-run the new script against fresh DOM.
|
||||||
|
if (loadedScripts.get(p.id) !== (p.version || '')) continue;
|
||||||
|
const screenOk = !p.has_screen || !!document.getElementById(`plugin-${p.id}`);
|
||||||
|
const settingsOk = !p.has_settings || existingSettingsByPluginId.has(p.id);
|
||||||
|
if (screenOk && settingsOk) {
|
||||||
|
alreadyHydrated.add(p.id);
|
||||||
|
} else {
|
||||||
|
// DOM was wiped externally (uninstall + reinstall, snapshot
|
||||||
|
// churn) — drop the entry and remove the orphaned <script>
|
||||||
|
// so screen.js re-runs against fresh DOM below.
|
||||||
|
loadedScripts.delete(p.id);
|
||||||
|
_removePluginScriptTags(p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear plugin-owned containers, but keep already-hydrated plugins'
|
||||||
|
// settings / screen DOM. Nav links carry no per-plugin script state,
|
||||||
|
// so always rebuild them.
|
||||||
|
navContainer.innerHTML = '';
|
||||||
|
mobileNavContainer.innerHTML = '<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>';
|
||||||
|
for (const container of _pluginSettingsContainers()) {
|
||||||
|
[...container.children].forEach((el) => {
|
||||||
|
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||||
|
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||||
|
// about but that failed hydration; leave plugins absent from
|
||||||
|
// the response untouched (see respondedIds above).
|
||||||
|
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||||
|
// dataset.pluginId is the source of truth (set on injection);
|
||||||
|
// the id-prefix fallback covers screens injected before this
|
||||||
|
// change shipped — both forms strip a single leading "plugin-".
|
||||||
|
const pid = (el.dataset && el.dataset.pluginId)
|
||||||
|
|| el.id.replace(/^plugin-/, '');
|
||||||
|
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||||
|
// collapsibles. Reveal it whenever any plugins are installed —
|
||||||
|
// updates are relevant even for plugins that contribute no settings.
|
||||||
|
if (plugins.length > 0) {
|
||||||
|
const area = document.getElementById('plugin-settings-area');
|
||||||
|
if (area) area.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build plugin dropdown for desktop nav
|
||||||
|
const navPlugins = plugins.map(plugin => ({ plugin, nav: _pluginNav(plugin) })).filter(entry => entry.nav);
|
||||||
|
if (navPlugins.length > 0) {
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'relative';
|
||||||
|
dropdown.innerHTML = `
|
||||||
|
<button class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
||||||
|
Plugins
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="hidden absolute top-full left-0 mt-2 bg-dark-800 border border-gray-700 rounded-xl shadow-xl py-2 min-w-[180px] max-h-[80vh] overflow-y-auto z-50" id="plugin-dropdown"></div>`;
|
||||||
|
navContainer.appendChild(dropdown);
|
||||||
|
const ddMenu = dropdown.querySelector('#plugin-dropdown');
|
||||||
|
|
||||||
|
// Close the plugin dropdown when clicking outside it. Bind ONCE:
|
||||||
|
// loadPlugins() re-runs on every plugin status change during
|
||||||
|
// startup (SSE-driven refetches), and each run rebuilds `dropdown`
|
||||||
|
// / `ddMenu`. A per-run addEventListener would leak a new global
|
||||||
|
// click listener on every refetch, each closing over a now-detached
|
||||||
|
// dropdown. The one-time handler instead resolves the LIVE dropdown
|
||||||
|
// from the DOM at click time, so it always targets the current one.
|
||||||
|
if (!window.feedBack._pluginDropdownOutsideClickBound) {
|
||||||
|
window.feedBack._pluginDropdownOutsideClickBound = true;
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const menu = document.getElementById('plugin-dropdown');
|
||||||
|
if (!menu) return;
|
||||||
|
const container = menu.parentElement;
|
||||||
|
if (container && !container.contains(e.target)) menu.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { plugin, nav } of navPlugins) {
|
||||||
|
const screenId = `plugin-${plugin.id}`;
|
||||||
|
// A plugin is navigable only once it's ready. While its deps
|
||||||
|
// install (status "installing") or after a failed load
|
||||||
|
// (status "failed") we still render the nav slot — disabled,
|
||||||
|
// with an "installing…" suffix or the error as a tooltip — so
|
||||||
|
// the nav is stable and the user sees the plugin is coming
|
||||||
|
// (#421). Entries without a status (legacy / stub) are ready.
|
||||||
|
const status = plugin.status || 'ready';
|
||||||
|
const isReady = status === 'ready';
|
||||||
|
// nav is truthy here (navPlugins is filtered on entry.nav), and
|
||||||
|
// is the computed value from _pluginNav() — which may be a
|
||||||
|
// string, an object that omits `label`, or a synthesized object
|
||||||
|
// (e.g. the Capability Inspector). _navLabel() normalizes all
|
||||||
|
// three and falls back to name/id so a missing label never
|
||||||
|
// renders "undefined" or throws. Use the loop's `nav`, not the
|
||||||
|
// raw `plugin.nav`, so string and synthesized labels survive.
|
||||||
|
const label = _navLabel(nav, plugin);
|
||||||
|
|
||||||
|
const item = document.createElement('a');
|
||||||
|
item.href = '#';
|
||||||
|
ddMenu.appendChild(item);
|
||||||
|
// Mobile nav — flat list
|
||||||
|
const ma = document.createElement('a');
|
||||||
|
ma.href = '#';
|
||||||
|
mobileNavContainer.appendChild(ma);
|
||||||
|
|
||||||
|
if (isReady) {
|
||||||
|
item.className = 'block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-dark-700 transition';
|
||||||
|
item.textContent = label;
|
||||||
|
item.onclick = (e) => { e.preventDefault(); ddMenu.classList.add('hidden'); window.showScreen(screenId); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
||||||
|
ma.className = 'text-gray-400 hover:text-white pl-4 text-sm';
|
||||||
|
ma.textContent = label;
|
||||||
|
ma.onclick = (e) => { e.preventDefault(); window.showScreen(screenId); ma.closest('#mobile-menu').classList.add('hidden'); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
||||||
|
} else {
|
||||||
|
const installing = status === 'installing';
|
||||||
|
const suffix = installing ? ' (installing…)' : ' (failed)';
|
||||||
|
const tip = installing
|
||||||
|
? 'This plugin is installing its dependencies and will become available shortly.'
|
||||||
|
: (plugin.error || 'This plugin failed to load. Check the server startup log for details.');
|
||||||
|
// Disabled appearance: dimmed, default cursor, no nav handler.
|
||||||
|
const cls = 'block px-4 py-2 text-sm text-gray-600 cursor-default select-none'
|
||||||
|
+ (installing ? ' animate-pulse' : '');
|
||||||
|
item.className = cls;
|
||||||
|
item.setAttribute('aria-disabled', 'true');
|
||||||
|
item.title = tip;
|
||||||
|
item.textContent = label + suffix;
|
||||||
|
// Drop disabled entries out of the tab order and strip the
|
||||||
|
// href so keyboard/screen-reader users don't land on a
|
||||||
|
// non-actionable "link" (a11y). Swallow clicks too, in case
|
||||||
|
// it's still reached via mouse.
|
||||||
|
item.removeAttribute('href');
|
||||||
|
item.setAttribute('tabindex', '-1');
|
||||||
|
item.onclick = (e) => { e.preventDefault(); };
|
||||||
|
ma.className = 'pl-4 text-sm text-gray-600 cursor-default select-none' + (installing ? ' animate-pulse' : '');
|
||||||
|
ma.setAttribute('aria-disabled', 'true');
|
||||||
|
ma.title = tip;
|
||||||
|
ma.textContent = label + suffix;
|
||||||
|
ma.removeAttribute('href');
|
||||||
|
ma.setAttribute('tabindex', '-1');
|
||||||
|
ma.onclick = (e) => { e.preventDefault(); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tear down stylesheets for plugins that are gone / no longer styled
|
||||||
|
// before (re)injecting for the current set.
|
||||||
|
_reconcilePluginStyles(plugins);
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
try {
|
||||||
|
// Only ready plugins have their assets available (the backend
|
||||||
|
// guards screen.html/screen.js/settings.html on status=="ready").
|
||||||
|
// Installing/failed plugins contribute only the disabled nav slot
|
||||||
|
// built above — skip screen/settings/script injection for them.
|
||||||
|
if (plugin.status && plugin.status !== 'ready') continue;
|
||||||
|
await _registerLegacyPluginUiContributions(plugin);
|
||||||
|
const screenId = `plugin-${plugin.id}`;
|
||||||
|
|
||||||
|
// Inject the plugin's stylesheet FIRST (before screen HTML/JS) so
|
||||||
|
// its utilities are present on first paint. Idempotent + version-
|
||||||
|
// deduped, so it's safe to call for already-hydrated plugins too.
|
||||||
|
_injectPluginStyles(plugin);
|
||||||
|
|
||||||
|
// Inject screen container. Skip for already-hydrated plugins —
|
||||||
|
// their existing screen DOM still has the listeners that
|
||||||
|
// screen.js bound on first load (rebuilding here would orphan
|
||||||
|
// them, since the script load guard further down won't re-run
|
||||||
|
// screen.js to re-bind).
|
||||||
|
if (plugin.has_screen && !alreadyHydrated.has(plugin.id)) {
|
||||||
|
const screenDiv = document.createElement('div');
|
||||||
|
screenDiv.id = screenId;
|
||||||
|
screenDiv.className = 'screen';
|
||||||
|
screenDiv.dataset.pluginId = plugin.id;
|
||||||
|
screenDiv.dataset.pluginVersion = plugin.version || '';
|
||||||
|
// Insert before the player screen
|
||||||
|
const player = document.getElementById('player');
|
||||||
|
player.parentNode.insertBefore(screenDiv, player);
|
||||||
|
|
||||||
|
const htmlResp = await fetch(`/api/plugins/${plugin.id}/screen.html`);
|
||||||
|
screenDiv.innerHTML = await htmlResp.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject settings section — wrapped in a collapsible <details>
|
||||||
|
// per plugin so the page stays scannable as plugins accumulate.
|
||||||
|
// Collapsed by default; <details>/<summary> handles state natively.
|
||||||
|
// Skip for already-hydrated plugins — preserved details element
|
||||||
|
// still carries listeners wired by its inline settings script
|
||||||
|
// and by screen.js on first load.
|
||||||
|
// Resolve which settings tab this plugin's panel mounts under
|
||||||
|
// (manifest settings.category), falling back to '#plugin-settings'.
|
||||||
|
const settingsTarget = plugin.has_settings ? _pluginSettingsTarget(plugin) : null;
|
||||||
|
if (plugin.has_settings && settingsTarget && !alreadyHydrated.has(plugin.id)) {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = 'bg-dark-700/40 border border-gray-800 rounded-xl overflow-hidden group';
|
||||||
|
details.dataset.pluginId = plugin.id;
|
||||||
|
details.dataset.pluginVersion = plugin.version || '';
|
||||||
|
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
// .plugin-settings-summary class hides the browser's native
|
||||||
|
// disclosure triangle (see style.css) so only our chevron shows.
|
||||||
|
// flex-col allows the fallback explanation note to appear below
|
||||||
|
// the name/badges row when plugin.fallback is set.
|
||||||
|
summary.className = 'plugin-settings-summary cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-300 hover:bg-dark-700/70 transition flex flex-col';
|
||||||
|
// Inner row: plugin name/badges (left) + chevron (right).
|
||||||
|
const headerRow = document.createElement('span');
|
||||||
|
headerRow.className = 'flex items-center justify-between';
|
||||||
|
const labelWrap = document.createElement('span');
|
||||||
|
labelWrap.className = 'flex items-center gap-2';
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.textContent = plugin.name || plugin.id;
|
||||||
|
labelWrap.appendChild(labelSpan);
|
||||||
|
// "Bundled" marker (feedBack#160). Visually distinguishes
|
||||||
|
// plugins that ship with the default container image from
|
||||||
|
// user-installed ones so users don't try to remove a core
|
||||||
|
// plugin via the manage-plugin flow and brick a feature
|
||||||
|
// that's expected to "just work".
|
||||||
|
if (plugin.bundled) {
|
||||||
|
const bundledDesc = 'This plugin ships with FeedBack core and is expected to be present.';
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-purple-400/30 bg-purple-500/10 text-purple-300';
|
||||||
|
badge.title = bundledDesc;
|
||||||
|
badge.setAttribute('aria-label', 'Bundled — ' + bundledDesc);
|
||||||
|
badge.setAttribute('role', 'img');
|
||||||
|
badge.innerHTML = `
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3zM6 11h12a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2v-6a2 2 0 012-2z"/>
|
||||||
|
</svg>
|
||||||
|
Bundled
|
||||||
|
`;
|
||||||
|
labelWrap.appendChild(badge);
|
||||||
|
}
|
||||||
|
// "Fallback" warning badge: the bundled copy failed to load its
|
||||||
|
// routes, so the server fell back to this older user-installed
|
||||||
|
// copy. Warn users so they know the bundled build is broken and
|
||||||
|
// can check the server startup log for the root cause.
|
||||||
|
if (plugin.fallback) {
|
||||||
|
const fbBadge = document.createElement('span');
|
||||||
|
fbBadge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-yellow-400/40 bg-yellow-500/10 text-yellow-300';
|
||||||
|
fbBadge.setAttribute('aria-hidden', 'true');
|
||||||
|
fbBadge.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg> Fallback';
|
||||||
|
labelWrap.appendChild(fbBadge);
|
||||||
|
}
|
||||||
|
// Assemble inner header row: [name/badges (left)] [chevron (right)].
|
||||||
|
// Both are placed in headerRow so the fallback note (if any)
|
||||||
|
// can sit below the entire row as a second flex-col child of
|
||||||
|
// summary, rather than being squeezed inline beside the chevron.
|
||||||
|
headerRow.appendChild(labelWrap);
|
||||||
|
// Chevron icon — built via setAttributeNS so the SVG sits in
|
||||||
|
// the SVG namespace and renders correctly. Plugin label is
|
||||||
|
// appended as text above so manifest values can't inject HTML.
|
||||||
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(svgNS, 'svg');
|
||||||
|
svg.setAttribute('class', 'w-4 h-4 text-gray-500 transition-transform group-open:rotate-180');
|
||||||
|
svg.setAttribute('fill', 'none');
|
||||||
|
svg.setAttribute('stroke', 'currentColor');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
const svgPath = document.createElementNS(svgNS, 'path');
|
||||||
|
svgPath.setAttribute('stroke-linecap', 'round');
|
||||||
|
svgPath.setAttribute('stroke-linejoin', 'round');
|
||||||
|
svgPath.setAttribute('stroke-width', '2');
|
||||||
|
svgPath.setAttribute('d', 'M19 9l-7 7-7-7');
|
||||||
|
svg.appendChild(svgPath);
|
||||||
|
headerRow.appendChild(svg);
|
||||||
|
summary.appendChild(headerRow);
|
||||||
|
// Fallback explanation note: a visible <p> below the header row,
|
||||||
|
// accessible to touch/keyboard users (browser tooltip via title/
|
||||||
|
// aria-label alone is hover-only and insufficient). Appended to
|
||||||
|
// summary (not labelWrap) so it renders as the second child in
|
||||||
|
// summary's flex-col layout, appearing below the name+badges row.
|
||||||
|
if (plugin.fallback) {
|
||||||
|
const fbNote = document.createElement('span');
|
||||||
|
fbNote.className = 'block text-xs text-yellow-300/80 mt-1';
|
||||||
|
fbNote.textContent = 'The bundled version failed to start. This user-installed copy is serving as a fallback. Check the server startup log for details.';
|
||||||
|
summary.appendChild(fbNote);
|
||||||
|
}
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.id = `plugin-settings-${plugin.id}`;
|
||||||
|
body.className = 'px-4 py-4 border-t border-gray-800 space-y-4';
|
||||||
|
details.appendChild(body);
|
||||||
|
|
||||||
|
settingsTarget.appendChild(details);
|
||||||
|
|
||||||
|
const settingsResp = await fetch(`/api/plugins/${plugin.id}/settings.html`);
|
||||||
|
body.innerHTML = await settingsResp.text();
|
||||||
|
// <script> tags inserted via innerHTML are intentionally
|
||||||
|
// inert per the HTML5 spec — the browser parses them as
|
||||||
|
// DOM nodes but never runs the body. That silently breaks
|
||||||
|
// any plugin settings.html that wires event handlers via
|
||||||
|
// addEventListener (e.g. file pickers, anything that
|
||||||
|
// can't be expressed as an inline onclick=… attribute),
|
||||||
|
// and any inline IIFE that hydrates form values from
|
||||||
|
// localStorage. Re-create each script node — script
|
||||||
|
// elements created via document.createElement DO execute
|
||||||
|
// when appended — so plugins get the script behavior
|
||||||
|
// they'd expect from a normal HTML document.
|
||||||
|
body.querySelectorAll('script').forEach(oldScript => {
|
||||||
|
const newScript = document.createElement('script');
|
||||||
|
for (const attr of oldScript.attributes) {
|
||||||
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
|
}
|
||||||
|
newScript.textContent = oldScript.textContent;
|
||||||
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load plugin JS
|
||||||
|
if (plugin.has_script) {
|
||||||
|
const wantedVersion = plugin.version || '';
|
||||||
|
if (loadedScripts.get(plugin.id) !== wantedVersion) {
|
||||||
|
// A different version (or none) was loaded previously —
|
||||||
|
// remove the prior <script> tag for this plugin id so we
|
||||||
|
// don't accumulate stale versions on upgrade/downgrade.
|
||||||
|
_removePluginScriptTags(plugin.id);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
// Include version in URL so a plugin upgrade within the
|
||||||
|
// same browser session fetches the new screen.js instead
|
||||||
|
// of a cached copy keyed only by path (matches the art
|
||||||
|
// URL ?v=mtime convention elsewhere in this file).
|
||||||
|
const v = encodeURIComponent(wantedVersion);
|
||||||
|
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||||
|
// Module-migration (R0): a migrated plugin declares
|
||||||
|
// scriptType:"module" and its screen.js is `import
|
||||||
|
// './src/main.js'`. A <script type="module"> fires load
|
||||||
|
// only after its whole static-import graph evaluates, so
|
||||||
|
// the await-onload completion + _loadingPluginId contract
|
||||||
|
// below is preserved (a classic-IIFE dynamic import()
|
||||||
|
// would not). Classic plugins are unaffected.
|
||||||
|
if (plugin.script_type === 'module') script.type = 'module';
|
||||||
|
script.dataset.pluginId = plugin.id;
|
||||||
|
script.dataset.pluginVersion = wantedVersion;
|
||||||
|
window.feedBack._loadingPluginId = plugin.id;
|
||||||
|
script.onload = () => {
|
||||||
|
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
||||||
|
loadedScripts.set(plugin.id, wantedVersion);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = (err) => {
|
||||||
|
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
||||||
|
loadedScripts.delete(plugin.id);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Plugin '${plugin.id}' failed to load, skipping:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load plugins:', e);
|
||||||
|
// Restore nav so a failed re-hydration call doesn't leave it blank.
|
||||||
|
if (_savedNav !== null && navContainer) navContainer.innerHTML = _savedNav;
|
||||||
|
if (_savedMobileNav !== null && mobileNavContainer) mobileNavContainer.innerHTML = _savedMobileNav;
|
||||||
|
_loadPluginsInFlight = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_loadPluginsInFlight = false;
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run loadPlugins (and the viz picker, since a newly-ready plugin may
|
||||||
|
// register a window.feedBackViz_<id> factory) when plugin status changes.
|
||||||
|
// Debounced so a burst of plugin-registered/plugin-error events during
|
||||||
|
// startup collapses into a single refetch.
|
||||||
|
let _pluginRefreshTimer = null;
|
||||||
|
function _refreshPluginsSoon() {
|
||||||
|
clearTimeout(_pluginRefreshTimer);
|
||||||
|
_pluginRefreshTimer = setTimeout(async () => {
|
||||||
|
const plugins = await loadPlugins();
|
||||||
|
if (plugins) {
|
||||||
|
_host.populateVizPicker(plugins);
|
||||||
|
} else {
|
||||||
|
// loadPlugins() returned null because a refetch was already in
|
||||||
|
// flight, so this status change would otherwise be dropped. Re-arm
|
||||||
|
// the debounce so the newer state is still applied once the
|
||||||
|
// in-flight load finishes. Reuses the 250ms delay (and the
|
||||||
|
// in-flight guard clears quickly), so this can't tight-loop.
|
||||||
|
_refreshPluginsSoon();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _pluginStreamStarted = false;
|
||||||
|
function _streamPluginStartup() {
|
||||||
|
// Watch the SAME /api/startup-status/stream the splash used to gate on.
|
||||||
|
// Instead of blocking, we let the nav render immediately (loadPlugins ran
|
||||||
|
// already) and refetch whenever a plugin graduates to ready or fails — so
|
||||||
|
// its nav slot flips from "installing…" to active/failed without a reload
|
||||||
|
// (#421). loadPlugins is idempotent (in-flight guard + version map), so
|
||||||
|
// extra refetches are cheap and safe.
|
||||||
|
if (_pluginStreamStarted) return;
|
||||||
|
_pluginStreamStarted = true;
|
||||||
|
|
||||||
|
if (typeof EventSource === 'undefined') { _pollPluginStartup(); return; }
|
||||||
|
|
||||||
|
const es = new EventSource('/api/startup-status/stream');
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
let status;
|
||||||
|
try { status = JSON.parse(event.data); } catch { return; }
|
||||||
|
if (!status || status.type === 'keepalive') return;
|
||||||
|
const phase = (status.phase || '').trim();
|
||||||
|
if (phase === 'plugin-registered' || phase === 'plugin-error') {
|
||||||
|
_refreshPluginsSoon();
|
||||||
|
}
|
||||||
|
// Terminal: one last refetch to catch anything missed, then stop.
|
||||||
|
if (!status.running && (phase === 'complete' || phase === 'error')) {
|
||||||
|
_refreshPluginsSoon();
|
||||||
|
es.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
// Stream dropped (proxy buffering, backend hiccup). Stop retrying the
|
||||||
|
// stream and fall back to a bounded poll so late installs still surface.
|
||||||
|
es.close();
|
||||||
|
_pollPluginStartup();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let _pollStartupStarted = false;
|
||||||
|
async function _pollPluginStartup() {
|
||||||
|
// SSE-unavailable fallback: poll /api/startup-status until the backend
|
||||||
|
// finishes its plugin loader, refetching whenever the ready count changes
|
||||||
|
// or it goes terminal. Bounded so a backend that never finishes doesn't
|
||||||
|
// poll forever.
|
||||||
|
if (_pollStartupStarted) return;
|
||||||
|
_pollStartupStarted = true;
|
||||||
|
// Generous headroom over the documented worst case (whisperx → torch et al.
|
||||||
|
// can take 20-30 min): a 30-min ceiling would stop polling right as a
|
||||||
|
// slipping install — slow mirror, pip retry — actually finishes. 60 min
|
||||||
|
// leaves margin so the late graduation still surfaces. (#421)
|
||||||
|
const DEADLINE_MS = 60 * 60 * 1000;
|
||||||
|
const start = Date.now();
|
||||||
|
// Track a composite signature, not just the ready count: a plugin can fail
|
||||||
|
// (phase → "plugin-error", current_plugin/error change) without changing
|
||||||
|
// `loaded`, e.g. the next plugin breaks after all prior ones succeeded.
|
||||||
|
// Watching only `loaded` would miss that transition until some later
|
||||||
|
// ready-count change or terminal completion, so the failed/error nav state
|
||||||
|
// wouldn't surface. Refetch whenever any of these move.
|
||||||
|
let lastSig = null;
|
||||||
|
while (Date.now() - start < DEADLINE_MS) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/startup-status');
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
const status = await resp.json();
|
||||||
|
const sig = JSON.stringify([
|
||||||
|
Number(status.loaded || 0),
|
||||||
|
status.phase || '',
|
||||||
|
status.current_plugin || '',
|
||||||
|
status.error || '',
|
||||||
|
]);
|
||||||
|
if (sig !== lastSig) { lastSig = sig; _refreshPluginsSoon(); }
|
||||||
|
if (!status.running) { _refreshPluginsSoon(); return; }
|
||||||
|
} catch (_e) { /* network error — keep trying */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapPluginsAndUi() {
|
||||||
|
// #421: never gate the nav on full plugin startup. Render it immediately
|
||||||
|
// from /api/plugins (ready plugins active; installing/failed disabled),
|
||||||
|
// then stream plugin status so each entry resolves in place as its
|
||||||
|
// dependencies finish installing or its load fails.
|
||||||
|
const plugins = await loadPlugins();
|
||||||
|
_streamPluginStartup();
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..', '..');
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||||
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
|
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
|
||||||
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
|
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
|
||||||
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
|
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
|
||||||
@@ -28,7 +28,7 @@ test('capability inspector manifest ships settings but no default nav entry', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('capability inspector plugins menu entry is localStorage opt-in', () => {
|
test('capability inspector plugins menu entry is localStorage opt-in', () => {
|
||||||
const src = source(APP_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
|
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
|
||||||
const menu = region(src, 'const navPlugins = plugins.map', 1000);
|
const menu = region(src, 'const navPlugins = plugins.map', 1000);
|
||||||
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
|
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
|
||||||
@@ -106,7 +106,7 @@ test('capability inspector screen ships scoped graph lane CSS', () => {
|
|||||||
assert.match(html, /left: -1\.75rem/);
|
assert.match(html, /left: -1\.75rem/);
|
||||||
});
|
});
|
||||||
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
|
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
|
||||||
const src = source(APP_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
|
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
|
||||||
assert.ok(m, 'could not extract _navLabel from app.js');
|
assert.ok(m, 'could not extract _navLabel from app.js');
|
||||||
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
|
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
|
||||||
@@ -123,7 +123,7 @@ test('_navLabel resolves string, object, synthesized, and empty nav values', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
|
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
|
||||||
const src = source(APP_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
// Regression guard for the string/synthesized-nav label fix: the dropdown
|
// Regression guard for the string/synthesized-nav label fix: the dropdown
|
||||||
// label must derive from the loop's computed nav via _navLabel, not from
|
// label must derive from the loop's computed nav via _navLabel, not from
|
||||||
// plugin.nav?.label (which drops string and synthesized labels).
|
// plugin.nav?.label (which drops string and synthesized labels).
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ test('native audio-mix participant suppresses matching legacy fader and records
|
|||||||
|
|
||||||
const ROOT = path.join(__dirname, '..', '..');
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||||
|
// The plugin loader was carved out of app.js into its own module (R3a); the
|
||||||
|
// library-provider code below still lives in app.js.
|
||||||
|
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||||
|
|
||||||
function source(file) {
|
function source(file) {
|
||||||
@@ -87,7 +90,7 @@ function region(src, needle, length = 1200) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
||||||
const src = source(APP_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
||||||
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
||||||
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
// Verify loadPlugins' plugin-DOM wipe loops in static/js/plugin-loader.js: a plugin that is
|
||||||
// merely ABSENT from the current /api/plugins response (transient partial
|
// merely ABSENT from the current /api/plugins response (transient partial
|
||||||
// response while the backend's plugin registry is repopulating after a
|
// response while the backend's plugin registry is repopulating after a
|
||||||
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
||||||
@@ -14,7 +14,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||||
|
|
||||||
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
||||||
// nav reset that opens it to the comment introducing the next section.
|
// nav reset that opens it to the comment introducing the next section.
|
||||||
@@ -40,7 +40,7 @@ function makeEl(pluginId, id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||||
const block = extractWipeBlock(src);
|
const block = extractWipeBlock(src);
|
||||||
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
||||||
const container = { children: settingsChildren };
|
const container = { children: settingsChildren };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Guards the R0 module-migration loader change in static/app.js: a migrated
|
// Guards the R0 module-migration loader change in static/js/plugin-loader.js: a migrated
|
||||||
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
||||||
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
||||||
// graph loads, while classic plugins stay untouched.
|
// graph loads, while classic plugins stay untouched.
|
||||||
@@ -16,8 +16,8 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||||
|
|
||||||
// Isolate the screen.js <script> injection block: from where its src is built
|
// Isolate the screen.js <script> injection block: from where its src is built
|
||||||
// to where the element is appended.
|
// to where the element is appended.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Verify the plugin `styles` capability in static/app.js: _injectPluginStyles
|
// Verify the plugin `styles` capability in static/js/plugin-loader.js: _injectPluginStyles
|
||||||
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
|
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
|
||||||
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
|
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
|
||||||
// without `styles`, and routes the URL through the sandboxed asset endpoint.
|
// without `styles`, and routes the URL through the sandboxed asset endpoint.
|
||||||
@@ -9,7 +9,7 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||||
|
|
||||||
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
|
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
|
||||||
// nested object/template literal can't make a naive regex stop early.
|
// nested object/template literal can't make a naive regex stop early.
|
||||||
@@ -84,7 +84,7 @@ function setupSandbox() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||||
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
|
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
|
||||||
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
|
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
|
||||||
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
|
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import pytest
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
WORKSPACE_ROOT = ROOT.parent
|
WORKSPACE_ROOT = ROOT.parent
|
||||||
|
|
||||||
|
# The plugin loader was carved out of static/app.js into its own module (R3a).
|
||||||
|
# These tests assert on its source text, so they read it from its new home.
|
||||||
|
PLUGIN_LOADER = ROOT / "static" / "js" / "plugin-loader.js"
|
||||||
|
|
||||||
|
|
||||||
def _sibling_file(plugin_dir: str, filename: str) -> Path:
|
def _sibling_file(plugin_dir: str, filename: str) -> Path:
|
||||||
path = WORKSPACE_ROOT / plugin_dir / filename
|
path = WORKSPACE_ROOT / plugin_dir / filename
|
||||||
@@ -23,7 +27,7 @@ def _sibling_text(plugin_dir: str, filename: str, required_token: str | None = N
|
|||||||
|
|
||||||
|
|
||||||
def test_plugin_loader_guards_duplicate_hydration_and_scripts():
|
def test_plugin_loader_guards_duplicate_hydration_and_scripts():
|
||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "let _loadPluginsInFlight = false" in source
|
assert "let _loadPluginsInFlight = false" in source
|
||||||
assert "window.feedBack._loadedPluginScripts" in source
|
assert "window.feedBack._loadedPluginScripts" in source
|
||||||
@@ -31,7 +35,7 @@ def test_plugin_loader_guards_duplicate_hydration_and_scripts():
|
|||||||
|
|
||||||
|
|
||||||
def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering():
|
def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering():
|
||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "const _pluginUiContributions = new Map()" in source
|
assert "const _pluginUiContributions = new Map()" in source
|
||||||
assert "await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution)" in source
|
assert "await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution)" in source
|
||||||
@@ -48,7 +52,7 @@ def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
|
|||||||
# (plugin scripts don't re-run), and the DOM/style wipes forced a
|
# (plugin scripts don't re-run), and the DOM/style wipes forced a
|
||||||
# mid-session screen.js re-evaluation that duplicated the desktop
|
# mid-session screen.js re-evaluation that duplicated the desktop
|
||||||
# audio_engine's native signal chain.
|
# audio_engine's native signal chain.
|
||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# The absence-triggered sweep is gone (rationale comment in its place)...
|
# The absence-triggered sweep is gone (rationale comment in its place)...
|
||||||
assert "const livePluginIds" not in source
|
assert "const livePluginIds" not in source
|
||||||
@@ -154,7 +158,13 @@ def test_deferred_runtime_domains_remain_reserved_not_bridged():
|
|||||||
|
|
||||||
|
|
||||||
def test_capability_events_do_not_bridge_deferred_surfaces():
|
def test_capability_events_do_not_bridge_deferred_surfaces():
|
||||||
app_source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
# These are NEGATIVE assertions, so they must span every file the code could
|
||||||
|
# have moved to — otherwise carving a function out of app.js turns the guard
|
||||||
|
# vacuous instead of failing.
|
||||||
|
app_source = (
|
||||||
|
(ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
||||||
|
+ PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
capability_source = (ROOT / "static" / "capabilities.js").read_text(encoding="utf-8")
|
capability_source = (ROOT / "static" / "capabilities.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
for token in ["return 'ui.navigation'", "return 'note-detection'", "eventName.startsWith('viz:') || eventName.startsWith('highway:')"]:
|
for token in ["return 'ui.navigation'", "return 'note-detection'", "eventName.startsWith('viz:') || eventName.startsWith('highway:')"]:
|
||||||
@@ -164,7 +174,7 @@ def test_capability_events_do_not_bridge_deferred_surfaces():
|
|||||||
|
|
||||||
|
|
||||||
def test_plugin_loader_registers_manifest_capability_declarations():
|
def test_plugin_loader_registers_manifest_capability_declarations():
|
||||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
source = PLUGIN_LOADER.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))" in source
|
assert "const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))" in source
|
||||||
assert "capabilityApi.registerParticipants(capabilityPlugins)" in source
|
assert "capabilityApi.registerParticipants(capabilityPlugins)" in source
|
||||||
|
|||||||
Reference in New Issue
Block a user