diff --git a/server.py b/server.py index f5b236b..2b22882 100644 --- a/server.py +++ b/server.py @@ -1715,3 +1715,13 @@ def index(): def index_v3(): # Retained as a back-compat alias for links minted while v3 was opt-in. return FileResponse(str(STATIC_DIR / "v3" / "index.html")) + + +@app.get("/pane") +def pane_host(): + # The document a popped-out pane runs in. Deliberately NOT the app shell with + # a query flag (the splitscreen follower's approach): that loads the library, + # the highway and the whole v3 shell only to hide them again, and pays for it + # with anti-flash hacks in three files. This page loads the pane runtime and + # nothing else. See docs/plugin-panes.md. + return FileResponse(str(STATIC_DIR / "panes" / "pane.html")) diff --git a/static/panes/builtin/mixer-pane.js b/static/panes/builtin/mixer-pane.js index f531be7..24367f3 100644 --- a/static/panes/builtin/mixer-pane.js +++ b/static/panes/builtin/mixer-pane.js @@ -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 diff --git a/static/panes/builtin/now-playing.js b/static/panes/builtin/now-playing.js index cbf7d64..f617310 100644 --- a/static/panes/builtin/now-playing.js +++ b/static/panes/builtin/now-playing.js @@ -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, diff --git a/static/panes/pane-bridge.js b/static/panes/pane-bridge.js index f0c4381..24e7de1 100644 --- a/static/panes/pane-bridge.js +++ b/static/panes/pane-bridge.js @@ -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 + const streamSubs = new Map(); // stream name -> Set + 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, }; })(); diff --git a/static/panes/pane-hub.js b/static/panes/pane-hub.js new file mode 100644 index 0000000..5ea55a8 --- /dev/null +++ b/static/panes/pane-hub.js @@ -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, 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); } + }); +})(); diff --git a/static/panes/pane-manager.js b/static/panes/pane-manager.js index 451b459..67581de 100644 --- a/static/panes/pane-manager.js +++ b/static/panes/pane-manager.js @@ -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 || {}; diff --git a/static/panes/pane-runtime.js b/static/panes/pane-runtime.js new file mode 100644 index 0000000..447ee3a --- /dev/null +++ b/static/panes/pane-runtime.js @@ -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