mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
Three things a plugin needs before it can actually use panes.
## mirrorGlobal — the camera-director problem
The 3D highways read their free camera from a plain global,
`window.__h3dCamCtl` (highway_3d/FREECAM_BRIDGE.md), once per frame in
_resolveFreeCam(). A camera panel in the main window just writes that
object and the camera moves. A panel in a POP-OUT window cannot:
window.__h3dCamCtl there is a different object in a different realm, and
writing it moves nothing.
So a pane declares one field — `mirrorGlobal: '__h3dCamCtl'` — and
pane-mirror.js (main realm, where the renderers live) copies that pane's
state onto the global whenever it changes. highway_3d, keys_highway_3d
and drum_highway_3d are NOT modified and do not know panes exist.
The rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A
renderer may be holding the reference, and swapping in a new object would
leave it reading an orphan. Keys the pane doesn't set are left alone
rather than deleted — the global may carry a renderer's own bookkeeping.
Closing the pane deliberately leaves the global as-is: closing the camera
panel should not snap the camera back to a default, which is exactly what
happens today (nobody clears __h3dCamCtl).
## Manifest-declared panes
"panes": [{ "id": "camera_director", "title": "Camera Director",
"script": "panes/camera.js", "mirrorGlobal": "__h3dCamCtl" }]
Declaring a pane beats calling panes.register() from screen.js because it
becomes openable FROM THE RAIL OR THE TRAY WITHOUT THE PLUGIN'S SCREEN
EVER HAVING BEEN VISITED — core registers a stub from the manifest and
fetches the script only when the user opens it. A pane you can only reach
by first navigating to the screen it was meant to replace is not much of a
pane.
The script sets `window.feedBackPane_<id> = { mount, unmount }`, mirroring
the existing window.feedBackViz_<id> convention, and the SAME file is what
a pop-out window loads in its own realm.
`script` is validated as a relpath under the plugin's src/ and served
through the sandboxed /api/plugins/<id>/src/ route — the containment rule
`styles` already has for assets/. Traversal, absolute paths, drive letters,
backslashes and non-.js are rejected; a bad entry is dropped with a warning
rather than failing the whole plugin, because one malformed pane should not
cost the user a working plugin.
Note the projection is written TWICE — _nav_entry() and the /api/plugins
route re-project independently — so panes had to be added to both, plus the
pending branch (a pane can be opened while its plugin is still installing
deps; the script is fetched on open, not at discovery).
## docs/plugin-panes.md
The contract, and the one rule it all hangs on: mount(root, ctx) runs in a
realm that may not have the app in it. Everything comes through ctx, or the
pane works docked and silently dies popped out.
Verified: manifest validation rejects ../.., C:\, non-.js, dupes and
missing fields while passing a good entry; /api/plugins projects panes[] for
all 20 plugins. mirrorGlobal mutates the global IN PLACE — a reference held
the way _resolveFreeCam holds it sees the change, and a renderer's own field
on that object survives — both for a local write and for a write arriving
over the channel from a pop-out realm.
pytest: 2401 passed, 8 failed — all 8 reproduce on a clean main (including
the one in tests/test_plugins.py) and are unrelated.
Signed-off-by: topkoa <topkoa@gmail.com>
123 lines
4.8 KiB
JavaScript
123 lines
4.8 KiB
JavaScript
/*
|
|
* fee[dB]ack — manifest-declared plugin panes.
|
|
*
|
|
* A plugin can ship a pane by declaring it in plugin.json, with no code in
|
|
* screen.js at all:
|
|
*
|
|
* "panes": [{
|
|
* "id": "camera_director",
|
|
* "title": "Camera Director",
|
|
* "icon": "🎥",
|
|
* "script": "panes/camera.js",
|
|
* "mirrorGlobal": "__h3dCamCtl"
|
|
* }]
|
|
*
|
|
* The point of declaring it rather than calling panes.register() is that the
|
|
* pane becomes openable — from the rail, from the tray — WITHOUT THE PLUGIN'S
|
|
* SCREEN EVER HAVING BEEN VISITED. We register a stub from the manifest and
|
|
* fetch the script only when the user actually opens it. A pane you can only
|
|
* reach by first navigating to the screen it was supposed to replace is not
|
|
* much of a pane.
|
|
*
|
|
* The script sets a factory global, the same shape the viz contract already uses
|
|
* (`window.feedBackViz_<id>`):
|
|
*
|
|
* window.feedBackPane_camera_director = {
|
|
* mount(root, ctx) { ... },
|
|
* unmount(root, ctx) { ... },
|
|
* };
|
|
*
|
|
* It is loaded from the sandboxed /api/plugins/<plugin>/src/<script> route, and
|
|
* the SAME file is what a pop-out window loads in its own realm — so it must not
|
|
* assume the app is there. `ctx` is everything it gets. See docs/plugin-panes.md.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const panes = window.feedBack && window.feedBack.panes;
|
|
if (!panes) return;
|
|
|
|
// Loaded factories, so re-opening a pane doesn't re-fetch its script.
|
|
const loading = new Map(); // paneId -> Promise<factory>
|
|
|
|
function _factory(paneId) {
|
|
return window['feedBackPane_' + paneId] || null;
|
|
}
|
|
|
|
function _load(paneId, url) {
|
|
const already = _factory(paneId);
|
|
if (already) return Promise.resolve(already);
|
|
if (loading.has(paneId)) return loading.get(paneId);
|
|
|
|
const p = new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = url;
|
|
s.onload = () => {
|
|
const f = _factory(paneId);
|
|
if (f) resolve(f);
|
|
else reject(new Error('pane script loaded but set no window.feedBackPane_' + paneId));
|
|
};
|
|
s.onerror = () => reject(new Error('failed to load pane script: ' + url));
|
|
document.head.appendChild(s);
|
|
});
|
|
loading.set(paneId, p);
|
|
p.catch(() => loading.delete(paneId)); // let a transient failure be retried
|
|
return p;
|
|
}
|
|
|
|
function register(plugin, def) {
|
|
const url = '/api/plugins/' + plugin.id + '/src/' + def.script;
|
|
|
|
panes.register({
|
|
id: def.id,
|
|
title: def.title || def.id,
|
|
icon: def.icon || '▣',
|
|
// The pop-out realm loads exactly the same file.
|
|
script: url,
|
|
defaultHost: def.defaultHost || 'window',
|
|
mirrorGlobal: def.mirrorGlobal || null,
|
|
width: def.width || undefined,
|
|
height: def.height || undefined,
|
|
|
|
// The stub. Docked, the script is fetched on first open and its
|
|
// factory takes over from here; the root is already on screen, so a
|
|
// slow fetch shows an empty card rather than blocking the click.
|
|
mount(root, ctx) {
|
|
_load(def.id, url).then((factory) => {
|
|
if (!root.isConnected) return; // closed again while we were fetching
|
|
factory.mount(root, ctx);
|
|
root.__fbPaneFactory = factory;
|
|
}).catch((err) => {
|
|
console.error('[panes]', def.id, err);
|
|
const oops = document.createElement('div');
|
|
oops.className = 'fb-pane-dim';
|
|
oops.textContent = 'This pane failed to load.';
|
|
root.appendChild(oops);
|
|
});
|
|
},
|
|
|
|
unmount(root, ctx) {
|
|
const factory = root.__fbPaneFactory;
|
|
delete root.__fbPaneFactory;
|
|
// Only the factory that actually mounted gets to unmount. A pane
|
|
// closed before its script arrived never mounted, and calling
|
|
// unmount() on it would hand the plugin a root it has never seen.
|
|
if (factory && typeof factory.unmount === 'function') {
|
|
try { factory.unmount(root, ctx); } catch (e) { console.error('[panes]', def.id, 'unmount threw', e); }
|
|
}
|
|
root.replaceChildren();
|
|
},
|
|
});
|
|
}
|
|
|
|
fetch('/api/plugins')
|
|
.then((r) => r.json())
|
|
.then((list) => {
|
|
(Array.isArray(list) ? list : []).forEach((plugin) => {
|
|
if (plugin.enabled === false || !Array.isArray(plugin.panes)) return;
|
|
plugin.panes.forEach((def) => register(plugin, def));
|
|
});
|
|
})
|
|
.catch((err) => console.error('[panes] could not read the plugin list', err));
|
|
})();
|