mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
feat(panes): pop-out windows — the pane realm, hub, and remote transport
A pane can now leave the main window entirely. Same `mount(root, ctx)`,
same file, different JS realm — which is what the ctx-only contract in the
previous commit was for.
## A purpose-built document, not the app shell with a flag on it
`GET /pane` serves static/panes/pane.html: the bridge, the runtime, and
the pane's own script. No highway, no library, no v3 shell, no <audio>,
no Tailwind.
The splitscreen follower takes the other road — it reloads the whole app
at `/?ssFollower=1` and hides what it doesn't want — and pays for it with
an anti-flash block that must run before any script parses (index.html),
bail-outs in app.js and shell.js, and ~40 lines of CSS hiding core
elements by id. It loads the entire app to throw it away. A pane window
has nothing to throw away, so it boots in milliseconds and there is
nothing to flash.
The cost is that `window.feedBack` in a pane realm is a deliberate,
documented SUBSET. The runtime installs exactly what a pane is promised —
`panes.register`, and the no-op chip/dock calls a shared script may make
at load — so a pane reaching for something it was never given fails
loudly at authoring time instead of subtly at runtime.
## The channel
BroadcastChannel('feedback-panes'), same origin. This works only because
Electron's setWindowOpenHandler returns `action: 'allow'` for same-origin
URLs: `deny` would push the window to the system browser, a different
Chromium instance, where BroadcastChannel cannot reach it and the pane
would silently never sync. That flag is load-bearing.
hello -> snapshot resync-on-open, always. The snapshot is the only way
the pane realm learns anything.
state main is authoritative. A pane's write is a REQUEST;
main applies it and echoes to every realm, so a
losing write self-corrects instead of splitting brain.
rpc / rpc:reply ctx.call() -> the capability bus, with a 10s deadline.
Without one, a main window that died mid-call leaves
the pane's promise pending forever.
event allowlisted bus events, JSON-safe. A CustomEvent
carrying a DOM node (highway:canvas-replaced does)
would throw on postMessage and take the channel down
for everyone, so detail is round-tripped through JSON.
stream one coalesced message per pane per frame, OVERWRITING
anything not yet flushed. Queueing would build a
backlog: Chromium throttles a backgrounded window, and
the main window is exactly what's backgrounded while
the user looks at the pane.
sub / unsub refcounts the main-realm sampler.
bye both directions.
## The follower clock
The pane extrapolates between broadcasts: anchor + observedRate * elapsed,
capped at 2s. observedRate is learned from the broadcasts themselves
(dt/dwall) so it tracks the speed slider without being told about it, and
seeks/pauses are excluded from the fit — a jump is not a tempo. Capping it
means a dead main window decays into a frozen clock rather than one that
confidently runs away. This is splitscreen's hard-won trick, generalized:
panes just call ctx.playhead().
## Failure modes, all of them
- Main window closes -> `bye {main-closed}` and the pane says so plainly,
rather than showing a frozen playhead that looks live. The host also
closes its windows outright; a pane that cannot be fed should not be on
screen.
- Pane window X'd or crashed -> a `closed` poll reaps it (a crashed
renderer never sends `bye`), the pane closes, and the chip's dialog comes
back. Without this the user's dialog stays hidden with no way back.
- Popup blocked -> a toast, and we bail BEFORE the manager records
anything, so the caller's dialog stays exactly where it was.
- Nobody answers `hello` in 5s -> the window says so instead of spinning.
- A pane with no `script` is a closure in this realm and cannot honestly
cross a window boundary. The window host declines it (canHost) and the
router falls back to the dock.
- A browser blocks window.open() outside a user gesture, so a popped-out
pane cannot be auto-restored on page load — it would only ever produce a
"blocked" toast. Such a pane comes back in the DOCK, and the chip pops it
out again on the next click. (autoRestore: false. The desktop host will
set it true.)
Hosts may now declare `remote: true`, meaning the pane's mount() runs in
another realm: the manager then owns only the authoritative state store and
never calls mount() itself. That is the seam the Electron BrowserWindow +
tray host drops into next, with no change here.
Verified: popped Now Playing and Mixer into real windows. The pane realm has
no window.highway, no capability bus and no <audio>, yet the Mixer renders
its faders via ctx.call('audio-mix','list-faders') across the channel — and
dragging that fader IN THE PANE WINDOW moved the main window's song volume
to 55 and persisted it. Closing the pane window un-hid the mixer dialog,
removed the stub and restored the chip, while the other pane window stayed
open.
Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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(() => {
|
||||
if (!captured) { fail('The pane script loaded but registered nothing.'); return; }
|
||||
statusEl.hidden = true;
|
||||
rootEl.hidden = false;
|
||||
try {
|
||||
captured.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);
|
||||
})();
|
||||
Reference in New Issue
Block a user