mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-18 22:42:25 +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>
342 lines
15 KiB
JavaScript
342 lines
15 KiB
JavaScript
/*
|
|
* fee[dB]ack — pane manager.
|
|
*
|
|
* The registry and host router behind `window.feedBack.panes`. Main realm only.
|
|
*
|
|
* A "pane" is a piece of live UI — a mixer, a camera rig, a readout — authored
|
|
* once as `mount(root, ctx)` and mountable into any *host*: the in-window dock
|
|
* today, a pop-out OS window later. The manager owns which pane is open and
|
|
* where; hosts own the chrome; the pane owns nothing but its own DOM.
|
|
*
|
|
* The problem this exists to solve: the player's rail popovers are exclusive
|
|
* (opening one closes the last), so you cannot watch the mixer while riding the
|
|
* camera — and both vanish the moment you want to look at the highway. Panes
|
|
* are non-exclusive by construction and survive song switches, because nothing
|
|
* about them is tied to the per-song teardown.
|
|
*
|
|
* Hosts register themselves; the manager never imports one. That is what lets
|
|
* the pop-out window host drop in later without this file changing.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const B = window.__fbPaneBridge;
|
|
if (!B) { console.error('[panes] pane-bridge.js must load before pane-manager.js'); return; }
|
|
|
|
const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload
|
|
const STATE_KEY = (id) => 'fbPane:' + id;
|
|
|
|
// id -> normalized spec
|
|
const specs = new Map();
|
|
// id -> { spec, hostId, root, ctx, state }
|
|
const open = new Map();
|
|
// hostId -> host provider
|
|
const hosts = new Map();
|
|
|
|
// ── Persistence ──────────────────────────────────────────────────────────
|
|
// localStorage is shared with any pop-out realm (same origin), so a
|
|
// concurrent writer there would silently clobber us. The rule, enforced by
|
|
// this file being main-realm-only: THE MAIN REALM IS THE ONLY WRITER. A pane
|
|
// asks; the manager writes.
|
|
|
|
function _readJSON(key, fallback) {
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
return raw ? JSON.parse(raw) : fallback;
|
|
} catch (e) { return fallback; } // private mode / corrupt value
|
|
}
|
|
function _writeJSON(key, value) {
|
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ }
|
|
}
|
|
|
|
function _rememberHost(id, hostId) {
|
|
const map = _readJSON(HOSTS_KEY, {});
|
|
if (hostId) map[id] = hostId; else delete map[id];
|
|
_writeJSON(HOSTS_KEY, map);
|
|
}
|
|
|
|
// Pane state is saved on a trailing debounce — a fader drag writes on every
|
|
// input event, and localStorage is synchronous.
|
|
const _saveTimers = new Map();
|
|
function _scheduleSave(id, state) {
|
|
clearTimeout(_saveTimers.get(id));
|
|
_saveTimers.set(id, setTimeout(() => {
|
|
_saveTimers.delete(id);
|
|
_writeJSON(STATE_KEY(id), state.all());
|
|
}, 250));
|
|
}
|
|
|
|
// ── Spec ─────────────────────────────────────────────────────────────────
|
|
|
|
function _normalize(spec) {
|
|
if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object');
|
|
if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required');
|
|
if (typeof spec.mount !== 'function') throw new TypeError('panes.register(' + spec.id + '): spec.mount is required');
|
|
return {
|
|
id: spec.id,
|
|
title: spec.title || spec.id,
|
|
icon: spec.icon || '▣',
|
|
mount: spec.mount,
|
|
unmount: typeof spec.unmount === 'function' ? spec.unmount : null,
|
|
// Bus events mirrored into a pop-out realm for this pane. Docked, ctx.on()
|
|
// reaches the real bus regardless — this list only matters once the
|
|
// pane is in another realm, and it is declared here so it is the same
|
|
// list in both.
|
|
events: Array.isArray(spec.events) ? B.DEFAULT_EVENTS.concat(spec.events) : B.DEFAULT_EVENTS,
|
|
persist: spec.persist !== false, // default on; opt out with `persist: false`
|
|
initialState: spec.initialState || {},
|
|
defaultHost: spec.defaultHost || 'window',
|
|
// URL of the module the pane REALM loads to obtain this pane's
|
|
// mount(). A pane with no script can only ever be docked — it exists
|
|
// solely as a closure in this realm, and there is no honest way to
|
|
// move a closure across a window boundary. The window host declines
|
|
// such panes and the router falls back to the dock.
|
|
script: spec.script || null,
|
|
mirrorGlobal: spec.mirrorGlobal || null, // honoured by pane-mirror.js
|
|
width: spec.width || 380,
|
|
height: spec.height || 560,
|
|
};
|
|
}
|
|
|
|
// ── Host routing ─────────────────────────────────────────────────────────
|
|
// A host provider is `{ id, priority, available(), mount(spec) -> Element,
|
|
// unmount(id), focus(id) }`. Higher priority wins when a pane asks for a
|
|
// host it can't have.
|
|
|
|
function _resolveHost(preferred, spec) {
|
|
const wanted = hosts.get(preferred);
|
|
if (wanted && wanted.available() && wanted.canHost(spec)) return wanted;
|
|
// Fall back to the best host that IS available and WILL take this pane,
|
|
// preferring the highest priority. The dock registers at priority 0 and
|
|
// accepts everything, so it is always the floor — a pane can never fail
|
|
// to open just because the window host is unavailable or declines it.
|
|
let best = null;
|
|
hosts.forEach((h) => {
|
|
if (!h.available() || !h.canHost(spec)) return;
|
|
if (!best || h.priority > best.priority) best = h;
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function _emit(name, detail) {
|
|
const bus = window.feedBack;
|
|
if (bus && typeof bus.emit === 'function') bus.emit(name, detail);
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────────
|
|
|
|
function register(spec) {
|
|
const s = _normalize(spec);
|
|
if (specs.has(s.id)) {
|
|
// First registration wins, matching libraryCardActions.register. A
|
|
// silent overwrite would let a re-injected plugin script swap the
|
|
// mount function out from under an already-open pane.
|
|
console.warn('[panes] pane already registered, ignoring:', s.id);
|
|
return () => {};
|
|
}
|
|
specs.set(s.id, s);
|
|
_emit('panes:registered', { id: s.id, title: s.title });
|
|
|
|
// Reopen where the user left it. Deferred a tick so a plugin can call
|
|
// register() and attachChip() back-to-back — the chip must exist before
|
|
// the pane opens or it has nothing to hide.
|
|
//
|
|
// A host may refuse to be auto-restored: a browser blocks window.open()
|
|
// without a user gesture, so restoring a popped-out pane on page load
|
|
// would only ever produce a "pop-up blocked" toast. Such a pane comes back
|
|
// in the dock, and the chip pops it out again on the user's next click.
|
|
// (The desktop host has no such restriction and restores in place.)
|
|
let remembered = _readJSON(HOSTS_KEY, {})[s.id];
|
|
if (remembered) {
|
|
const h = hosts.get(remembered);
|
|
if (h && h.autoRestore === false) remembered = 'dock';
|
|
setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
|
|
}
|
|
|
|
return () => unregister(s.id);
|
|
}
|
|
|
|
function unregister(id) {
|
|
if (open.has(id)) closePane(id, { remember: false });
|
|
specs.delete(id);
|
|
_emit('panes:unregistered', { id: id });
|
|
}
|
|
|
|
function openPane(id, opts) {
|
|
opts = opts || {};
|
|
const spec = specs.get(id);
|
|
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
|
|
if (open.has(id)) { focusPane(id); return true; }
|
|
|
|
const host = _resolveHost(opts.host || spec.defaultHost, spec);
|
|
if (!host) { console.error('[panes] open: no host available for', id); return false; }
|
|
|
|
const state = B.createStateStore(spec.persist ? _readJSON(STATE_KEY(id), spec.initialState) : spec.initialState);
|
|
if (spec.persist) state.subscribe(() => _scheduleSave(id, state));
|
|
|
|
// A REMOTE host (a pop-out window) runs the pane's mount() in its own
|
|
// realm — this realm never sees the pane's DOM and must not call mount()
|
|
// itself. All we own here is the authoritative state store; pane-hub.js
|
|
// serves the other realm from it.
|
|
if (host.remote) {
|
|
let handle;
|
|
try {
|
|
handle = host.mount(spec);
|
|
} catch (e) {
|
|
console.error('[panes] host', host.id, 'failed to open a window for', id, e);
|
|
return false;
|
|
}
|
|
if (!handle) return false; // host already explained itself (popup blocked, etc.)
|
|
open.set(id, { spec, hostId: host.id, state, remote: true, handle });
|
|
if (opts.remember !== false) _rememberHost(id, host.id);
|
|
_emit('panes:opened', { id: id, host: host.id });
|
|
return true;
|
|
}
|
|
|
|
let root;
|
|
try {
|
|
root = host.mount(spec);
|
|
} catch (e) {
|
|
console.error('[panes] host', host.id, 'failed to mount', id, e);
|
|
return false;
|
|
}
|
|
|
|
const ctx = B.createCtx({
|
|
paneId: id,
|
|
host: host.id,
|
|
transport: B.createLocalTransport(id),
|
|
state: state,
|
|
onClose: () => closePane(id),
|
|
});
|
|
|
|
const entry = { spec, hostId: host.id, root, ctx, state };
|
|
open.set(id, entry);
|
|
|
|
try {
|
|
spec.mount(root, ctx);
|
|
} catch (e) {
|
|
// A pane that throws in mount() must not leave a half-open shell
|
|
// behind — tear the whole thing back down and tell the user, rather
|
|
// than leaving an empty card they can't explain.
|
|
console.error('[panes] pane threw in mount():', id, e);
|
|
closePane(id, { remember: false });
|
|
if (window.fbNotify) window.fbNotify.show({ title: spec.title, message: 'Failed to open.', icon: '⚠️', accent: '#f59e0b' });
|
|
return false;
|
|
}
|
|
|
|
if (opts.remember !== false) _rememberHost(id, host.id);
|
|
_emit('panes:opened', { id: id, host: host.id });
|
|
return true;
|
|
}
|
|
|
|
function closePane(id, opts) {
|
|
opts = opts || {};
|
|
const entry = open.get(id);
|
|
if (!entry) return false;
|
|
open.delete(id);
|
|
|
|
// Order matters for a local pane: the pane tears down its own DOM and
|
|
// listeners first, then ctx drops everything it handed out, then the host
|
|
// removes the shell. Reversing any of these hands the pane a root that has
|
|
// already been detached, or leaks the subscriptions its unmount() assumed
|
|
// it kept. A remote pane's mount/unmount ran in the other realm and
|
|
// teardown goes with the window, so there is nothing to do but close it.
|
|
if (!entry.remote) {
|
|
try { if (entry.spec.unmount) entry.spec.unmount(entry.root, entry.ctx); }
|
|
catch (e) { console.error('[panes] pane threw in unmount():', id, e); }
|
|
try { entry.ctx._dispose(); } catch (e) { console.error('[panes] ctx dispose threw:', id, e); }
|
|
}
|
|
|
|
const host = hosts.get(entry.hostId);
|
|
try { if (host) host.unmount(id); } catch (e) { console.error('[panes] host', entry.hostId, 'threw in unmount:', id, e); }
|
|
|
|
// Flush any pending debounced state write — closing must not lose the
|
|
// last fader nudge.
|
|
if (entry.spec.persist) {
|
|
clearTimeout(_saveTimers.get(id));
|
|
_saveTimers.delete(id);
|
|
_writeJSON(STATE_KEY(id), entry.state.all());
|
|
}
|
|
|
|
if (opts.remember !== false) _rememberHost(id, null);
|
|
_emit('panes:closed', { id: id, host: entry.hostId });
|
|
return true;
|
|
}
|
|
|
|
// Move an open pane to a different host without losing its state: the pane's
|
|
// DOM is rebuilt (mount runs again against the new root) but its state store
|
|
// is the persisted one, so a fader sits where the user left it.
|
|
function movePane(id, hostId) {
|
|
const wasOpen = open.has(id);
|
|
if (wasOpen) closePane(id, { remember: false });
|
|
return openPane(id, { host: hostId });
|
|
}
|
|
|
|
function focusPane(id) {
|
|
const entry = open.get(id);
|
|
if (!entry) return false;
|
|
const host = hosts.get(entry.hostId);
|
|
if (host && typeof host.focus === 'function') host.focus(id);
|
|
return true;
|
|
}
|
|
|
|
// `detach` is what the pop-out chip calls: put this pane wherever a pane
|
|
// most wants to live. Today the dock is usually the only host; once the
|
|
// window host registers, it outranks the dock and the same call opens an OS
|
|
// window instead. The chip never changes.
|
|
function detach(id) {
|
|
const spec = specs.get(id);
|
|
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
|
|
}
|
|
|
|
function dock(id) { return movePane(id, 'dock'); }
|
|
|
|
function registerHost(host) {
|
|
if (!host || !host.id) throw new TypeError('panes: host needs an id');
|
|
hosts.set(host.id, {
|
|
id: host.id,
|
|
priority: host.priority || 0,
|
|
// `remote: true` means the pane's mount() runs in ANOTHER JS realm.
|
|
// The manager then owns only the state store, and pane-hub.js serves
|
|
// the pane over the channel.
|
|
remote: !!host.remote,
|
|
// `autoRestore: false` — this host cannot be opened without a user
|
|
// gesture, so a pane remembered here comes back in the dock instead.
|
|
autoRestore: host.autoRestore !== false,
|
|
available: typeof host.available === 'function' ? host.available : () => true,
|
|
canHost: typeof host.canHost === 'function' ? host.canHost : () => true,
|
|
mount: host.mount,
|
|
unmount: host.unmount,
|
|
focus: host.focus,
|
|
});
|
|
}
|
|
|
|
const api = {
|
|
version: 1,
|
|
register,
|
|
unregister,
|
|
open: openPane,
|
|
close: closePane,
|
|
move: movePane,
|
|
focus: focusPane,
|
|
detach,
|
|
dock,
|
|
isOpen: (id) => open.has(id),
|
|
hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; },
|
|
get: (id) => specs.get(id) || null,
|
|
list: () => Array.from(specs.values()).map((s) => ({ id: s.id, title: s.title, icon: s.icon, open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null })),
|
|
// Host registration is host-internal, but it lives on the same object so
|
|
// a future out-of-tree host (a plugin shipping its own window shell) can
|
|
// participate without a private import.
|
|
registerHost,
|
|
|
|
// Host-internal. pane-hub.js serves a pop-out realm from the
|
|
// authoritative state store, which only lives here. Not part of the pane
|
|
// API — panes must never reach for this.
|
|
_entry: (id) => open.get(id) || null,
|
|
};
|
|
|
|
window.feedBack = window.feedBack || {};
|
|
window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api);
|
|
})();
|