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>
399 lines
18 KiB
JavaScript
399 lines
18 KiB
JavaScript
/*
|
|
* fee[dB]ack — pane bridge.
|
|
*
|
|
* The transport + context layer for detachable panes. Zero DOM, zero UI.
|
|
*
|
|
* A pane is authored ONCE, as `mount(root, ctx)`, and must run unchanged in two
|
|
* places: docked in the main window, or inside a pop-out window (a separate JS
|
|
* realm where `window.feedBack`, `window.highway` and the audio graph do not
|
|
* exist). Everything a pane is allowed to touch therefore arrives through `ctx`
|
|
* — never through globals. That is the whole point of this file: it is the only
|
|
* seam between "pane code" and "which realm am I in".
|
|
*
|
|
* Two transports implement that seam:
|
|
*
|
|
* LocalTransport — main realm. Calls straight through to the capability bus,
|
|
* the feedBack event bus, and the stream sampler.
|
|
* RemoteTransport — pane realm (added with the pop-out window). Same methods,
|
|
* marshalled over BroadcastChannel.
|
|
*
|
|
* A pane cannot tell them apart, and must not try.
|
|
*
|
|
* Exposes `window.__fbPaneBridge` (host-internal — panes never touch it).
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// Bumped only on a breaking envelope change. The pane realm refuses to talk
|
|
// to a main realm with a different major, rather than half-working.
|
|
const PROTOCOL_VERSION = 1;
|
|
const CHANNEL_NAME = 'feedback-panes';
|
|
|
|
// Bus events mirrored into a pane realm by default. Deliberately an
|
|
// allowlist, not a firehose: `song:position-changed` fires every 250ms and
|
|
// `capability:event` fires constantly, and neither belongs on a
|
|
// cross-window channel — position rides the `playhead` stream instead.
|
|
// A pane widens this with `spec.events: [...]`.
|
|
const DEFAULT_EVENTS = [
|
|
'song:loading', 'song:loaded', 'song:ready',
|
|
'song:play', 'song:pause', 'song:ended', 'song:stop', 'song:seek',
|
|
'song:arrangement-changed',
|
|
'screen:changed', 'theme:changed', 'library:changed',
|
|
'highway:canvas-replaced', 'highway:visibility',
|
|
];
|
|
|
|
// ── State store ──────────────────────────────────────────────────────────
|
|
// A dotted-path key/value tree, one per pane. In the main realm this is the
|
|
// authoritative copy; a pane realm holds a replica and its writes are
|
|
// requests (see RemoteTransport). Subscribers get (snapshot, change).
|
|
|
|
function _split(path) {
|
|
if (typeof path !== 'string' || !path) throw new TypeError('pane state: path must be a non-empty string');
|
|
return path.split('.');
|
|
}
|
|
|
|
function createStateStore(initial) {
|
|
let data = (initial && typeof initial === 'object') ? JSON.parse(JSON.stringify(initial)) : {};
|
|
const subs = new Set();
|
|
|
|
function get(path) {
|
|
if (path == null) return data;
|
|
let node = data;
|
|
for (const k of _split(path)) {
|
|
if (node == null || typeof node !== 'object') return undefined;
|
|
node = node[k];
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function set(path, value) {
|
|
const keys = _split(path);
|
|
let node = data;
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
const k = keys[i];
|
|
// Walk-and-create. A non-object on the way down is replaced —
|
|
// the writer's shape wins over a stale scalar.
|
|
if (node[k] == null || typeof node[k] !== 'object') node[k] = {};
|
|
node = node[k];
|
|
}
|
|
const last = keys[keys.length - 1];
|
|
if (node[last] === value) return false; // no-op writes don't notify
|
|
node[last] = value;
|
|
const change = { path: path, value: value };
|
|
subs.forEach((fn) => { try { fn(data, change); } catch (e) { console.error('[panes] state subscriber threw', e); } });
|
|
return true;
|
|
}
|
|
|
|
// Bulk replace, used on snapshot/resync. Notifies once with a null change.
|
|
function replace(next) {
|
|
data = (next && typeof next === 'object') ? next : {};
|
|
subs.forEach((fn) => { try { fn(data, null); } catch (e) { console.error('[panes] state subscriber threw', e); } });
|
|
}
|
|
|
|
function subscribe(fn) {
|
|
subs.add(fn);
|
|
return () => subs.delete(fn);
|
|
}
|
|
|
|
return { get, set, replace, subscribe, all: () => data };
|
|
}
|
|
|
|
// ── Local transport (main realm) ─────────────────────────────────────────
|
|
|
|
const CALL_TIMEOUT_MS = 2100; // matches core's own audio-mix calls
|
|
const RPC_TIMEOUT_MS = 10000; // cross-realm deadline; generous, but never infinite
|
|
|
|
function createLocalTransport(paneId) {
|
|
return {
|
|
kind: 'local',
|
|
|
|
// Route to the capability bus. Panes get exactly this, and nothing
|
|
// else, as their door into app services — so a pane written against
|
|
// ctx.call() keeps working when it moves realms.
|
|
//
|
|
// A pane passes a plain PAYLOAD; the requester/origin/timeout
|
|
// envelope is core's to build. That keeps the pane-side call
|
|
// identical in both realms, where the remote transport has to
|
|
// reconstruct the envelope on this side of the channel anyway.
|
|
call(domain, command, payload) {
|
|
const caps = window.feedBack && window.feedBack.capabilities;
|
|
if (!caps || typeof caps.command !== 'function') {
|
|
return Promise.reject(new Error('pane ctx.call: capability bus unavailable'));
|
|
}
|
|
return caps.command(domain, command, {
|
|
requester: 'pane.' + paneId,
|
|
origin: 'pane',
|
|
payload: payload || {},
|
|
timeoutMs: CALL_TIMEOUT_MS,
|
|
});
|
|
},
|
|
|
|
on(name, fn) {
|
|
const bus = window.feedBack;
|
|
if (!bus || typeof bus.on !== 'function') return () => {};
|
|
bus.on(name, fn);
|
|
return () => bus.off(name, fn);
|
|
},
|
|
|
|
subscribe(stream, fn) {
|
|
const s = window.__fbPaneStreams;
|
|
if (!s) return () => {};
|
|
return s.subscribe(stream, fn);
|
|
},
|
|
|
|
// In the main realm the clock needs no interpolation — the highway's
|
|
// own time IS the source of truth. (The pane realm has to
|
|
// extrapolate; see RemoteTransport, added with the pop-out window.)
|
|
playhead() {
|
|
const hw = window.highway;
|
|
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : NaN;
|
|
return Number.isFinite(t) ? t : 0;
|
|
},
|
|
|
|
song() {
|
|
return (window.feedBack && window.feedBack.currentSong) || null;
|
|
},
|
|
|
|
toast(opts) {
|
|
if (window.fbNotify && typeof window.fbNotify.show === 'function') window.fbNotify.show(opts || {});
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── Envelopes ────────────────────────────────────────────────────────────
|
|
//
|
|
// { v, type, paneId, hostId, seq, payload }
|
|
//
|
|
// type: hello pane→main the pane realm booted; main replies `snapshot`
|
|
// snapshot main→pane spec + state + current song. Resync-on-open, always.
|
|
// state both { path, value }. Main is authoritative: a pane's
|
|
// write is a request; main applies it and echoes to
|
|
// every realm, so a losing write self-corrects.
|
|
// rpc pane→main { seq, domain, command, payload }
|
|
// rpc:reply main→pane { seq, ok, result | error }
|
|
// event main→pane a mirrored feedBack bus event { name, detail }
|
|
// stream main→pane coalesced numerics (playhead, meters)
|
|
// sub/unsub pane→main drives the main-realm sampler's refcount
|
|
// bye both clean teardown (main-closed / pane-closed)
|
|
|
|
function envelope(type, paneId, hostId, payload) {
|
|
return { v: PROTOCOL_VERSION, type: type, paneId: paneId, hostId: hostId, payload: payload };
|
|
}
|
|
|
|
function openChannel() {
|
|
if (typeof BroadcastChannel !== 'function') return null;
|
|
return new BroadcastChannel(CHANNEL_NAME);
|
|
}
|
|
|
|
// ── Remote transport (pane realm) ────────────────────────────────────────
|
|
|
|
const MAX_EXTRAP_S = 2.0; // never extrapolate the clock further than this
|
|
|
|
function createRemoteTransport(paneId, hostId, channel) {
|
|
const listeners = new Map(); // event name -> Set<fn>
|
|
const streamSubs = new Map(); // stream name -> Set<fn>
|
|
const pending = new Map(); // rpc seq -> { resolve, reject, timer }
|
|
let rpcSeq = 0;
|
|
let song = null;
|
|
|
|
// The follower clock. The main window broadcasts the playhead every frame
|
|
// — but Chromium throttles a BACKGROUNDED window's rAF to ~1 Hz, and the
|
|
// main window is exactly what's in the background while the user looks at
|
|
// this pane. So we extrapolate between messages instead of rendering 1 Hz
|
|
// stutter: anchor + observedRate * elapsed.
|
|
//
|
|
// observedRate is measured from the broadcasts themselves (Δt / Δwall), so
|
|
// it tracks the speed slider without being told about it. Capped at
|
|
// MAX_EXTRAP_S so a dead main window decays into a frozen clock rather
|
|
// than a clock that confidently runs away.
|
|
let anchorT = 0, anchorWall = 0, observedRate = 1, playing = false, duration = 0;
|
|
|
|
function _onPlayhead(p) {
|
|
const now = performance.now();
|
|
if (anchorWall && p.playing && playing) {
|
|
const dt = p.t - anchorT;
|
|
const dw = (now - anchorWall) / 1000;
|
|
// Ignore seeks and pauses when learning the rate: a jump is not a
|
|
// tempo. Only smooth, forward-moving deltas teach us anything.
|
|
if (dw > 0.05 && dt > 0 && dt < dw * 4) {
|
|
const r = dt / dw;
|
|
observedRate = observedRate * 0.8 + r * 0.2; // light smoothing
|
|
}
|
|
}
|
|
if (!p.playing) observedRate = 1; // a paused clock has no rate to learn
|
|
anchorT = p.t;
|
|
anchorWall = now;
|
|
playing = p.playing;
|
|
duration = p.duration;
|
|
}
|
|
|
|
function playhead() {
|
|
if (!anchorWall) return anchorT;
|
|
if (!playing) return anchorT;
|
|
const elapsed = Math.min(MAX_EXTRAP_S, (performance.now() - anchorWall) / 1000);
|
|
return anchorT + observedRate * elapsed;
|
|
}
|
|
|
|
function handle(msg) {
|
|
const p = msg.payload || {};
|
|
switch (msg.type) {
|
|
case 'event': {
|
|
const set_ = listeners.get(p.name);
|
|
// Shaped like a CustomEvent so a pane's handler is identical in
|
|
// both realms — `e.detail`, not `e`.
|
|
if (set_) set_.forEach((fn) => { try { fn({ detail: p.detail }); } catch (e) { console.error('[pane] event handler threw', e); } });
|
|
if (p.name === 'song:loaded') song = p.detail || null;
|
|
break;
|
|
}
|
|
case 'stream': {
|
|
if (p.playhead) _onPlayhead(p.playhead);
|
|
for (const name in p) {
|
|
const set_ = streamSubs.get(name);
|
|
if (set_) set_.forEach((fn) => { try { fn(p[name]); } catch (e) { console.error('[pane] stream handler threw', e); } });
|
|
}
|
|
break;
|
|
}
|
|
case 'rpc:reply': {
|
|
const call = pending.get(p.seq);
|
|
if (!call) return; // already timed out
|
|
pending.delete(p.seq);
|
|
clearTimeout(call.timer);
|
|
if (p.ok) call.resolve(p.result);
|
|
else call.reject(new Error(p.error || 'pane rpc failed'));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
kind: 'remote',
|
|
handle: handle,
|
|
setSong: (s) => { song = s; },
|
|
|
|
call(domain, command, payload) {
|
|
if (!channel) return Promise.reject(new Error('pane ctx.call: no channel'));
|
|
const seq = ++rpcSeq;
|
|
return new Promise((resolve, reject) => {
|
|
// Every call gets a deadline. Without one, a main window that
|
|
// died mid-call leaves the pane's promise pending forever and
|
|
// its UI stuck on "Pending".
|
|
const timer = setTimeout(() => {
|
|
pending.delete(seq);
|
|
reject(new Error('PaneRpcTimeout: ' + domain + '/' + command));
|
|
}, RPC_TIMEOUT_MS);
|
|
pending.set(seq, { resolve, reject, timer });
|
|
channel.postMessage(envelope('rpc', paneId, hostId, { seq, domain, command, payload: payload || {} }));
|
|
});
|
|
},
|
|
|
|
on(name, fn) {
|
|
let set_ = listeners.get(name);
|
|
if (!set_) { set_ = new Set(); listeners.set(name, set_); }
|
|
set_.add(fn);
|
|
return () => set_.delete(fn);
|
|
},
|
|
|
|
subscribe(stream, fn) {
|
|
let set_ = streamSubs.get(stream);
|
|
if (!set_) {
|
|
set_ = new Set();
|
|
streamSubs.set(stream, set_);
|
|
if (channel) channel.postMessage(envelope('sub', paneId, hostId, { stream }));
|
|
}
|
|
set_.add(fn);
|
|
return () => {
|
|
set_.delete(fn);
|
|
if (!set_.size && channel) channel.postMessage(envelope('unsub', paneId, hostId, { stream }));
|
|
};
|
|
},
|
|
|
|
playhead: playhead,
|
|
song: () => song,
|
|
|
|
// No toast stack in a pane window, and routing it back to the main
|
|
// window would pop the message up somewhere the user isn't looking.
|
|
toast(opts) { console.info('[pane]', (opts && opts.title) || '', (opts && opts.message) || ''); },
|
|
};
|
|
}
|
|
|
|
// ── ctx ──────────────────────────────────────────────────────────────────
|
|
// What a pane's mount() actually receives. Every subscription it hands out
|
|
// is tracked, so unmount() can drop them all — a pane physically cannot
|
|
// leak a listener across a dock/undock cycle, which is the failure mode
|
|
// that would otherwise show up as duplicate handlers after three song
|
|
// switches.
|
|
|
|
function createCtx(opts) {
|
|
const paneId = opts.paneId;
|
|
const transport = opts.transport;
|
|
const state = opts.state;
|
|
const disposers = [];
|
|
let disposed = false;
|
|
|
|
function track(unsub) {
|
|
if (typeof unsub !== 'function') return () => {};
|
|
// Late subscriptions (a pane calling ctx.on() from a setTimeout that
|
|
// outlived unmount) are torn down immediately rather than silently
|
|
// registered against a dead pane.
|
|
if (disposed) { try { unsub(); } catch (e) { /* already gone */ } return () => {}; }
|
|
disposers.push(unsub);
|
|
return () => {
|
|
const i = disposers.indexOf(unsub);
|
|
if (i >= 0) disposers.splice(i, 1);
|
|
try { unsub(); } catch (e) { /* already gone */ }
|
|
};
|
|
}
|
|
|
|
const ctx = {
|
|
paneId: paneId,
|
|
host: opts.host, // 'dock' | 'shared' | 'pane:<id>'
|
|
isRemote: transport.kind !== 'local',
|
|
|
|
state: {
|
|
get: (path) => state.get(path),
|
|
set: (path, value) => state.set(path, value),
|
|
all: () => state.all(),
|
|
subscribe: (fn) => track(state.subscribe(fn)),
|
|
},
|
|
|
|
call: (domain, command, args) => transport.call(domain, command, args),
|
|
on: (name, fn) => track(transport.on(name, fn)),
|
|
subscribe: (stream, fn) => track(transport.subscribe(stream, fn)),
|
|
playhead: () => transport.playhead(),
|
|
song: () => transport.song(),
|
|
toast: (o) => transport.toast(o),
|
|
|
|
// Ask the host to put this pane away. The pane does not know or care
|
|
// whether that means closing a dock card or an OS window.
|
|
close: () => { if (typeof opts.onClose === 'function') opts.onClose(); },
|
|
};
|
|
|
|
ctx._dispose = function () {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
// Copy-then-clear: a disposer that removes itself from the list
|
|
// (the closure returned by track) would otherwise skip its neighbour.
|
|
const list = disposers.slice();
|
|
disposers.length = 0;
|
|
list.forEach((fn) => { try { fn(); } catch (e) { console.error('[panes] disposer threw', e); } });
|
|
};
|
|
|
|
return ctx;
|
|
}
|
|
|
|
window.__fbPaneBridge = {
|
|
PROTOCOL_VERSION,
|
|
CHANNEL_NAME,
|
|
DEFAULT_EVENTS,
|
|
CALL_TIMEOUT_MS,
|
|
RPC_TIMEOUT_MS,
|
|
MAX_EXTRAP_S,
|
|
envelope,
|
|
openChannel,
|
|
createStateStore,
|
|
createLocalTransport,
|
|
createRemoteTransport,
|
|
createCtx,
|
|
};
|
|
})();
|