mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +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>
115 lines
5.0 KiB
JavaScript
115 lines
5.0 KiB
JavaScript
/*
|
|
* fee[dB]ack — the pop-out window host (browser path).
|
|
*
|
|
* Opens a real OS window per pane and hands it off to pane-hub.js, which serves
|
|
* it over BroadcastChannel. Registers as the `window` host at priority 10, so it
|
|
* outranks the dock and `panes.detach()` prefers it.
|
|
*
|
|
* This is the BROWSER implementation: a plain same-origin `window.open()`, which
|
|
* is what the splitscreen follower has done for years. Electron already permits
|
|
* it — main.ts's setWindowOpenHandler returns `action: 'allow'` for same-origin
|
|
* URLs, and that is load-bearing: `deny` would push the URL to the system
|
|
* browser, a different Chromium instance, where BroadcastChannel cannot reach it
|
|
* and the pane would silently never sync.
|
|
*
|
|
* The desktop app will register its own host at a higher priority (a real
|
|
* BrowserWindow, with a system tray, always-on-top, and remembered bounds).
|
|
* Nothing else changes when it does — that is the point of the host registry.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const panes = window.feedBack && window.feedBack.panes;
|
|
if (!panes || typeof panes.registerHost !== 'function') {
|
|
console.error('[panes] pane-manager.js must load before pane-window-host.js');
|
|
return;
|
|
}
|
|
|
|
const wins = new Map(); // paneId -> Window
|
|
let reaper = null;
|
|
|
|
// A pane window the user closed with the OS X button never gets to say `bye`
|
|
// reliably (a crashed renderer certainly doesn't). Poll `closed` and reap —
|
|
// otherwise the pane stays "open" forever, its chip stays stubbed out, and
|
|
// the user has no way back to their dialog. Same trick splitscreen uses.
|
|
function _startReaper() {
|
|
if (reaper != null) return;
|
|
reaper = setInterval(() => {
|
|
wins.forEach((w, id) => {
|
|
if (w.closed) panes.close(id); // → unmount() below clears the entry
|
|
});
|
|
if (!wins.size) { clearInterval(reaper); reaper = null; }
|
|
}, 500);
|
|
}
|
|
|
|
function mount(spec) {
|
|
const url = new URL(window.location.origin + '/pane');
|
|
url.searchParams.set('pane', spec.id);
|
|
// The pane realm loads its mount() from this URL. Everything else it
|
|
// needs (title, state, the song) arrives in the snapshot — URL params
|
|
// are open-time only and must never be a state channel.
|
|
url.searchParams.set('script', spec.script);
|
|
|
|
const w = window.open(url.toString(), 'fbpane-' + spec.id,
|
|
'popup,width=' + spec.width + ',height=' + spec.height);
|
|
|
|
if (!w) {
|
|
// Popup blocked. Bail BEFORE the manager records anything, so the
|
|
// caller's dialog stays exactly where it was — and say so out loud
|
|
// rather than appearing to do nothing.
|
|
if (window.fbNotify) {
|
|
window.fbNotify.show({
|
|
title: 'Pop-out blocked',
|
|
message: 'Allow pop-ups for this site to detach ' + spec.title + '.',
|
|
icon: '⚠️', accent: '#f59e0b',
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
wins.set(spec.id, w);
|
|
_startReaper();
|
|
return w;
|
|
}
|
|
|
|
function unmount(id) {
|
|
const w = wins.get(id);
|
|
wins.delete(id);
|
|
// Closing an already-closed window is a no-op, and closing one we opened
|
|
// is always permitted (same-origin, script-opened).
|
|
if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } }
|
|
}
|
|
|
|
function focus(id) {
|
|
const w = wins.get(id);
|
|
if (w && !w.closed) { try { w.focus(); } catch (e) { /* OS may refuse */ } }
|
|
}
|
|
|
|
panes.registerHost({
|
|
id: 'window',
|
|
priority: 10,
|
|
remote: true,
|
|
// A browser blocks window.open() outside a user gesture, so a pane
|
|
// remembered here cannot be restored on page load — it would only ever
|
|
// produce a "pop-up blocked" toast. The manager brings it back in the dock
|
|
// and the chip pops it out again on the user's next click. The desktop
|
|
// host overrides this: it opens real BrowserWindows and needs no gesture.
|
|
autoRestore: false,
|
|
// No BroadcastChannel means no way to feed the pane once it's open. Better
|
|
// to keep it docked than to open a window that renders forever-stale data.
|
|
available: () => typeof BroadcastChannel === 'function',
|
|
// A pane with no `script` exists only as a closure in this realm. There is
|
|
// no honest way to move a closure across a window boundary, so decline it
|
|
// and let the router fall back to the dock.
|
|
canHost: (spec) => !!spec.script,
|
|
mount, unmount, focus,
|
|
});
|
|
|
|
// The pane windows are ours; they must not outlive us. A pane window whose
|
|
// main window is gone can never be fed again — leaving it on screen showing a
|
|
// frozen playhead is worse than closing it.
|
|
window.addEventListener('beforeunload', () => {
|
|
wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } });
|
|
});
|
|
})();
|