Files
feedBack/static/panes/pane-runtime.js
T
topkoa 254e26bb3a feat(panes): mirrorGlobal, manifest-declared panes, and the plugin docs
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>
2026-07-12 17:38:05 -04:00

182 lines
7.5 KiB
JavaScript

/*
* fee[dB]ack — pane runtime (the pop-out realm).
*
* This is what runs inside a pane window. It is NOT the app: there is no
* highway, no library, no shell, no <audio>, no capability bus, no audio graph.
* There is this file, the bridge, and the pane's own script.
*
* That is deliberate. The splitscreen follower reuses the full app shell with a
* `?ssFollower=1` flag and pays for it — an anti-flash block that must run before
* any script parses, bail-outs in app.js and shell.js, and ~40 lines of CSS
* hiding core elements by id. It loads the whole app to throw it away. A pane
* window has nothing to throw away.
*
* The cost is that `window.feedBack` here is a deliberate, documented SUBSET.
* We install exactly what a pane is promised and nothing more, so a pane reaching
* for something it was never given fails loudly at authoring time instead of
* subtly at runtime.
*
* Boot: hello → snapshot → load the pane's script → mount(root, ctx).
*/
(function () {
'use strict';
const B = window.__fbPaneBridge;
const params = new URLSearchParams(location.search);
const paneId = params.get('pane');
const scriptUrl = params.get('script');
const rootEl = document.getElementById('pane-root');
const titleEl = document.getElementById('pane-title');
const statusEl = document.getElementById('pane-status');
function fail(message) {
statusEl.textContent = message;
statusEl.hidden = false;
rootEl.hidden = true;
}
if (!paneId || !scriptUrl) { fail('This window was opened without a pane to show.'); return; }
if (!B) { fail('Pane bridge failed to load.'); return; }
const channel = B.openChannel();
if (!channel) { fail('This browser has no BroadcastChannel, so a pane window cannot be kept in sync.'); return; }
// The registration shim. A pane script is the SAME file whether it runs in the
// app (where it registers with the real pane manager) or here — it calls
// `feedBack.panes.register(spec)` either way. Here, that call just hands us
// the spec.
let captured = null;
window.feedBack = {
panes: {
register(spec) {
if (spec && spec.id === paneId) captured = spec;
return () => {};
},
// A pane window hosts one pane. Chip/dock/launcher calls are
// meaningless here, but a shared pane script may make them at load —
// so they must exist and do nothing rather than throw and take the
// pane's module down with them.
attachChip: () => () => {},
get: () => null,
isOpen: () => false,
list: () => [],
},
};
const transport = B.createRemoteTransport(paneId, 'pane:' + paneId, channel);
let ctx = null;
let mounted = false;
channel.addEventListener('message', (e) => {
const msg = e.data;
if (!msg || msg.v !== B.PROTOCOL_VERSION || msg.paneId !== paneId) return;
if (msg.hostId !== 'main') return; // ignore our own echoes
if (msg.type === 'snapshot') { onSnapshot(msg.payload); return; }
if (msg.type === 'bye') {
const reason = (msg.payload && msg.payload.reason) || '';
// 'main-closed' is the interesting one: nothing will ever feed this
// window again. Say so plainly rather than leaving a frozen playhead
// that looks live.
if (reason === 'main-closed') fail('fee[dB]ack closed. This pane is no longer live.');
else window.close(); // closed deliberately from the app side
return;
}
if (msg.type === 'state' && state) {
// The authoritative echo. Applying it unconditionally is what makes a
// losing write self-correct.
state.set(msg.payload.path, msg.payload.value);
return;
}
transport.handle(msg);
});
let state = null;
function onSnapshot(snap) {
if (mounted) return; // a duplicate snapshot (main reloaded) — the window will be told `bye` if stale
titleEl.textContent = snap.spec.icon + ' ' + snap.spec.title;
document.title = snap.spec.title + ' — fee[dB]ack';
transport.setSong(snap.song);
state = B.createStateStore(snap.state);
// A pane's write is a REQUEST. We send it and let the main realm's echo
// apply it, so there is exactly one authority and no split brain. The
// local store is not written here — the echo does that.
const authoritative = {
get: (path) => state.get(path),
all: () => state.all(),
subscribe: (fn) => state.subscribe(fn),
set: (path, value) => {
channel.postMessage(B.envelope('state', paneId, 'pane:' + paneId, { path, value }));
return true;
},
};
ctx = B.createCtx({
paneId: paneId,
host: 'pane:' + paneId,
transport: transport,
state: authoritative,
onClose: () => window.close(),
});
loadScript(snap.spec.script).then(() => {
// Two ways a script can hand us a pane, and the same file must work in
// both realms:
// - a plugin pane sets window.feedBackPane_<id> (the factory global,
// mirroring the existing window.feedBackViz_<id> contract), because
// the main realm loads it lazily and must not have to re-register;
// - a core built-in calls feedBack.panes.register(), which our shim
// above captured.
const pane = window['feedBackPane_' + paneId] || captured;
if (!pane || typeof pane.mount !== 'function') {
fail('The pane script loaded but provided no pane.');
return;
}
statusEl.hidden = true;
rootEl.hidden = false;
try {
pane.mount(rootEl, ctx);
mounted = true;
} catch (err) {
console.error('[pane] mount() threw', err);
fail('This pane failed to start.');
}
}).catch((err) => {
console.error('[pane] failed to load', snap.spec.script, err);
fail('Could not load this pane.');
});
}
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = () => reject(new Error('script load failed: ' + url));
document.head.appendChild(s);
});
}
// Tell the app we're gone so it can un-hide the dialog the chip hid. If this
// never arrives (a crash), the host's `closed` poll reaps us anyway — but the
// clean path should not depend on the fallback.
window.addEventListener('beforeunload', () => {
try { channel.postMessage(B.envelope('bye', paneId, 'pane:' + paneId, { reason: 'pane-closed' })); }
catch (e) { /* channel already torn down */ }
});
// Resync-on-open, always: the snapshot is the only way this realm learns
// anything, so ask for it as the very first thing we do.
channel.postMessage(B.envelope('hello', paneId, 'pane:' + paneId, {}));
// If nobody answers, the main window is gone or never had this pane. Don't
// spin forever on a blank window.
setTimeout(() => { if (!mounted && !state) fail('fee[dB]ack is not running, or this pane is no longer available.'); }, 5000);
})();