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:
topkoa
2026-07-12 17:03:21 -04:00
parent e5cbea2e9f
commit fefb9051a4
11 changed files with 829 additions and 16 deletions
+1
View File
@@ -94,6 +94,7 @@
id: 'core_mixer',
title: 'Mixer',
icon: '🎚',
script: '/static/panes/builtin/mixer-pane.js',
persist: false,
// Faders come and go with the song (a song with no stems unregisters
// them), so the pane has to hear about it. These ride on top of the
+3
View File
@@ -40,6 +40,9 @@
id: 'now_playing',
title: 'Now Playing',
icon: '🎵',
// How the pane REALM loads this same file to get the same mount(). A pane
// with no `script` can only ever be docked.
script: '/static/panes/builtin/now-playing.js',
// Levels are transient; nothing here is worth remembering across a reload.
persist: false,
+162
View File
@@ -101,6 +101,7 @@
// ── 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 {
@@ -159,6 +160,162 @@
};
}
// ── 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
@@ -229,8 +386,13 @@
CHANNEL_NAME,
DEFAULT_EVENTS,
CALL_TIMEOUT_MS,
RPC_TIMEOUT_MS,
MAX_EXTRAP_S,
envelope,
openChannel,
createStateStore,
createLocalTransport,
createRemoteTransport,
createCtx,
};
})();
+221
View File
@@ -0,0 +1,221 @@
/*
* 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); }
});
})();
+69 -16
View File
@@ -86,6 +86,12 @@
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,
@@ -97,15 +103,16 @@
// unmount(id), focus(id) }`. Higher priority wins when a pane asks for a
// host it can't have.
function _resolveHost(preferred) {
function _resolveHost(preferred, spec) {
const wanted = hosts.get(preferred);
if (wanted && wanted.available()) return wanted;
// Fall back to the best host that IS available, preferring the highest
// priority. The dock registers at priority 0, so it is always the floor —
// a pane can never fail to open just because no window host exists.
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()) return;
if (!h.available() || !h.canHost(spec)) return;
if (!best || h.priority > best.priority) best = h;
});
return best;
@@ -133,8 +140,18 @@
// 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.
const remembered = _readJSON(HOSTS_KEY, {})[s.id];
if (remembered) setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
//
// 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);
}
@@ -151,12 +168,31 @@
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);
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);
@@ -199,13 +235,17 @@
if (!entry) return false;
open.delete(id);
// Order matters: the pane tears down its own DOM/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.
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); }
// 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); }
@@ -256,7 +296,15 @@
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,
@@ -281,6 +329,11 @@
// 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 || {};
+170
View File
@@ -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);
})();
+114
View File
@@ -0,0 +1,114 @@
/*
* 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 */ } } });
});
})();
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en" class="fb-pane-window">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pane — fee[dB]ack</title>
<link rel="icon" href="/static/assets/favicon.png">
<!-- Deliberately minimal. This is NOT the app shell with a flag on it: no
highway, no library, no v3 shell, no <audio>, no Tailwind. A pane window
has nothing to hide, so it needs no anti-flash hack and boots in
milliseconds. Panes needing utility classes ship their own stylesheet via
the `styles` manifest key, exactly as they must in the main window. -->
<link rel="stylesheet" href="/static/panes/panes.css">
</head>
<body>
<header class="fb-pane-window-head">
<span id="pane-title" class="fb-pane-window-title">fee[dB]ack</span>
</header>
<p id="pane-status" class="fb-pane-window-status">Connecting…</p>
<main id="pane-root" class="fb-pane-window-body fb-selectable" hidden></main>
<!-- Order matters: the bridge defines the transport the runtime uses. Both are
classic scripts, so the pane's own script (injected by the runtime) sees
the same global scope it would in the main window. -->
<script src="/static/panes/pane-bridge.js"></script>
<script src="/static/panes/pane-runtime.js"></script>
</body>
</html>
+49
View File
@@ -158,6 +158,55 @@
.fb-pane-card.is-flash { animation: none; }
}
/* ── The pop-out window (static/panes/pane.html) ─────────────────────────── */
/* This document loads no Tailwind and no app stylesheet — it is the whole page,
so it carries its own reset. Keep it tiny; a pane window must boot instantly. */
html.fb-pane-window,
html.fb-pane-window body {
margin: 0;
padding: 0;
height: 100%;
background: #0f172a;
color: #cbd5e1;
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
-webkit-user-select: none;
user-select: none; /* chrome isn't selectable; .fb-selectable opts content back in */
}
html.fb-pane-window body {
display: flex;
flex-direction: column;
}
.fb-pane-window-head {
flex: 0 0 auto;
padding: .55rem .8rem;
border-bottom: 1px solid rgba(51, 65, 85, .6);
background: rgba(30, 41, 59, .6);
-webkit-app-region: drag; /* the desktop host opens this frameless */
}
.fb-pane-window-title {
font-size: .8rem;
font-weight: 600;
color: #e2e8f0;
}
.fb-pane-window-status {
margin: 0;
padding: 1.25rem;
color: #64748b;
font-size: .8rem;
text-align: center;
}
.fb-pane-window-body {
flex: 1 1 auto;
overflow-y: auto;
padding: .85rem;
}
/* Core CSS defines this for the app; a pane window loads no app CSS, so repeat
it here. Same rule, same intent: copy-worthy text stays copyable. */
.fb-selectable, .fb-selectable * { -webkit-user-select: text; user-select: text; }
/* ── Shared widgets for built-in panes ───────────────────────────────────── */
.fb-pane-row {
+2
View File
@@ -1326,6 +1326,8 @@
<script defer src="/static/panes/pane-streams.js"></script>
<script defer src="/static/panes/pane-manager.js"></script>
<script defer src="/static/panes/pane-dock.js"></script>
<script defer src="/static/panes/pane-window-host.js"></script>
<script defer src="/static/panes/pane-hub.js"></script>
<script defer src="/static/panes/pane-chip.js"></script>
<script defer src="/static/panes/pane-launcher.js"></script>
<script defer src="/static/panes/builtin/now-playing.js"></script>