mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
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>
222 lines
9.8 KiB
JavaScript
222 lines
9.8 KiB
JavaScript
/*
|
|
* fee[dB]ack — pane hub (main realm).
|
|
*
|
|
* The server side of the pane channel. Every popped-out pane talks to exactly
|
|
* this file, and this file is the only thing in the app that knows a pane might
|
|
* be in another window.
|
|
*
|
|
* It answers `hello` with a snapshot, forwards allowlisted bus events, runs the
|
|
* pane's capability calls on its behalf, applies its state writes (the main realm
|
|
* is the sole authority), and samples the streams it asks for — pushing plain
|
|
* numbers, because the AnalyserNode behind `meters` can never cross a window
|
|
* boundary.
|
|
*
|
|
* Nothing here is reachable from a pane. A pane sees `ctx`, and `ctx` is all.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const B = window.__fbPaneBridge;
|
|
const panes = window.feedBack && window.feedBack.panes;
|
|
const bus = window.feedBack;
|
|
if (!B || !panes || !bus) { console.error('[panes] pane-hub.js loaded too early'); return; }
|
|
|
|
const channel = B.openChannel();
|
|
if (!channel) return; // no BroadcastChannel → the window host declines to open anything anyway
|
|
|
|
// paneId -> { streams: Map<name, unsub>, pending: Object|null, rafId }
|
|
const conns = new Map();
|
|
// Bus events we have hooked, so N panes share one listener per event.
|
|
const busHooks = new Map(); // event name -> { fn, refs }
|
|
|
|
function send(type, paneId, payload) {
|
|
channel.postMessage(B.envelope(type, paneId, 'main', payload));
|
|
}
|
|
|
|
// ── Bus mirroring ────────────────────────────────────────────────────────
|
|
|
|
function _hookEvent(name) {
|
|
let hook = busHooks.get(name);
|
|
if (hook) { hook.refs++; return; }
|
|
const fn = (e) => {
|
|
// Only forward to panes that actually want this event, and only
|
|
// structured-cloneable detail — a CustomEvent carrying a DOM node
|
|
// (highway:canvas-replaced does) would throw on postMessage and kill
|
|
// the channel for everyone.
|
|
let detail = null;
|
|
try { detail = JSON.parse(JSON.stringify(e.detail === undefined ? null : e.detail)); }
|
|
catch (err) { detail = null; } // not serialisable: the event still fires, sans payload
|
|
conns.forEach((_, paneId) => {
|
|
const spec = panes.get(paneId);
|
|
if (spec && spec.events.indexOf(name) >= 0) send('event', paneId, { name, detail });
|
|
});
|
|
};
|
|
bus.on(name, fn);
|
|
busHooks.set(name, { fn, refs: 1 });
|
|
}
|
|
|
|
function _unhookEvent(name) {
|
|
const hook = busHooks.get(name);
|
|
if (!hook) return;
|
|
if (--hook.refs > 0) return;
|
|
bus.off(name, hook.fn);
|
|
busHooks.delete(name);
|
|
}
|
|
|
|
// ── Streams ──────────────────────────────────────────────────────────────
|
|
//
|
|
// The sampler (pane-streams.js) fires per frame. We do NOT post per stream
|
|
// per frame — we coalesce every stream a pane wants into ONE message and post
|
|
// it on the next frame, overwriting anything not yet flushed.
|
|
//
|
|
// Overwriting rather than queueing is the whole trick: Chromium throttles a
|
|
// backgrounded window (which the MAIN window is, while the user looks at the
|
|
// pane), so a queue would grow a backlog of stale frames and then dump them.
|
|
// The pane extrapolates its own clock between whatever it does receive.
|
|
|
|
function _flush(paneId) {
|
|
const conn = conns.get(paneId);
|
|
if (!conn) return;
|
|
conn.rafId = null;
|
|
if (!conn.pending) return;
|
|
const payload = conn.pending;
|
|
conn.pending = null;
|
|
send('stream', paneId, payload);
|
|
}
|
|
|
|
function _onStreamValue(paneId, name, value) {
|
|
const conn = conns.get(paneId);
|
|
if (!conn) return;
|
|
if (!conn.pending) conn.pending = {};
|
|
conn.pending[name] = value; // last value for this frame wins
|
|
if (conn.rafId == null) conn.rafId = requestAnimationFrame(() => _flush(paneId));
|
|
}
|
|
|
|
function _subscribe(paneId, name) {
|
|
const conn = conns.get(paneId);
|
|
if (!conn || conn.streams.has(name)) return;
|
|
conn.streams.set(name, window.__fbPaneStreams.subscribe(name, (v) => _onStreamValue(paneId, name, v)));
|
|
}
|
|
|
|
function _unsubscribe(paneId, name) {
|
|
const conn = conns.get(paneId);
|
|
if (!conn) return;
|
|
const unsub = conn.streams.get(name);
|
|
if (unsub) { unsub(); conn.streams.delete(name); }
|
|
}
|
|
|
|
// ── Connections ──────────────────────────────────────────────────────────
|
|
|
|
function _connect(paneId) {
|
|
if (conns.has(paneId)) _disconnect(paneId); // a reloaded pane window says hello again
|
|
conns.set(paneId, { streams: new Map(), pending: null, rafId: null });
|
|
const spec = panes.get(paneId);
|
|
if (spec) spec.events.forEach(_hookEvent);
|
|
}
|
|
|
|
function _disconnect(paneId) {
|
|
const conn = conns.get(paneId);
|
|
if (!conn) return;
|
|
conn.streams.forEach((unsub) => unsub());
|
|
if (conn.rafId != null) cancelAnimationFrame(conn.rafId);
|
|
conns.delete(paneId);
|
|
const spec = panes.get(paneId);
|
|
if (spec) spec.events.forEach(_unhookEvent);
|
|
}
|
|
|
|
function _snapshot(paneId) {
|
|
const entry = panes._entry(paneId);
|
|
const spec = panes.get(paneId);
|
|
if (!entry || !spec) return null;
|
|
return {
|
|
spec: { id: spec.id, title: spec.title, icon: spec.icon, script: spec.script },
|
|
state: entry.state.all(),
|
|
song: (window.feedBack && window.feedBack.currentSong) || null,
|
|
};
|
|
}
|
|
|
|
// ── Channel ──────────────────────────────────────────────────────────────
|
|
|
|
channel.addEventListener('message', (e) => {
|
|
const msg = e.data;
|
|
if (!msg || msg.v !== B.PROTOCOL_VERSION || msg.hostId === 'main') return;
|
|
const paneId = msg.paneId;
|
|
const p = msg.payload || {};
|
|
|
|
switch (msg.type) {
|
|
case 'hello': {
|
|
const snap = _snapshot(paneId);
|
|
if (!snap) {
|
|
// The pane window outlived its registration (main window
|
|
// reloaded while a pane window stayed open). Tell it so it can
|
|
// close itself rather than sit there frozen.
|
|
send('bye', paneId, { reason: 'unknown-pane' });
|
|
return;
|
|
}
|
|
_connect(paneId);
|
|
send('snapshot', paneId, snap);
|
|
break;
|
|
}
|
|
|
|
case 'rpc': {
|
|
const caps = window.feedBack.capabilities;
|
|
const reply = (ok, result, error) => send('rpc:reply', paneId, { seq: p.seq, ok, result, error });
|
|
if (!caps || typeof caps.command !== 'function') { reply(false, null, 'capability bus unavailable'); return; }
|
|
caps.command(p.domain, p.command, {
|
|
requester: 'pane.' + paneId,
|
|
origin: 'pane',
|
|
payload: p.payload || {},
|
|
timeoutMs: B.CALL_TIMEOUT_MS,
|
|
}).then((result) => {
|
|
// The result crosses a window boundary, so it must survive
|
|
// structured clone. A capability that answers with a live
|
|
// object (a node, a function) would otherwise throw here and
|
|
// take the channel down with it.
|
|
let safe = null;
|
|
try { safe = JSON.parse(JSON.stringify(result === undefined ? null : result)); }
|
|
catch (err) { reply(false, null, 'result is not serialisable'); return; }
|
|
reply(true, safe, null);
|
|
}).catch((err) => reply(false, null, String((err && err.message) || err)));
|
|
break;
|
|
}
|
|
|
|
case 'state': {
|
|
const entry = panes._entry(paneId);
|
|
if (!entry) return;
|
|
// The main realm is authoritative: apply, then echo to everyone.
|
|
// A pane's own optimistic paint is corrected by the echo, so two
|
|
// panes racing on one key converge instead of diverging.
|
|
if (entry.state.set(p.path, p.value)) send('state', paneId, { path: p.path, value: p.value });
|
|
break;
|
|
}
|
|
|
|
case 'sub': _subscribe(paneId, p.stream); break;
|
|
case 'unsub': _unsubscribe(paneId, p.stream); break;
|
|
|
|
case 'bye': {
|
|
_disconnect(paneId);
|
|
// The pane window is going away for good (the user closed it).
|
|
// Closing the pane un-hides whatever dialog the chip hid, which is
|
|
// the only outcome that leaves the user able to find their UI again.
|
|
if (panes.isOpen(paneId)) panes.close(paneId);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// The main window is the only thing feeding the panes. When it goes, they
|
|
// cannot be fed — tell them, so they show a dead state instead of a
|
|
// convincing but frozen one. (The window host also closes them outright; this
|
|
// covers a host that can't, such as the desktop's own windows.)
|
|
window.addEventListener('beforeunload', () => {
|
|
conns.forEach((_, paneId) => send('bye', paneId, { reason: 'main-closed' }));
|
|
});
|
|
|
|
// A pane that is closed from THIS side (the stub, the launcher, the tray)
|
|
// must be told, or its window sits there orphaned.
|
|
bus.on('panes:closed', (e) => {
|
|
const id = e.detail && e.detail.id;
|
|
if (conns.has(id)) { send('bye', id, { reason: 'closed-by-host' }); _disconnect(id); }
|
|
});
|
|
})();
|