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>
182 lines
7.5 KiB
JavaScript
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);
|
|
})();
|