mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-27 23:31:54 +00:00
feat(panes): mirrorGlobal, manifest-declared panes, and the plugin docs
Three things a plugin needs before it can actually use panes.
## mirrorGlobal — the camera-director problem
The 3D highways read their free camera from a plain global,
`window.__h3dCamCtl` (highway_3d/FREECAM_BRIDGE.md), once per frame in
_resolveFreeCam(). A camera panel in the main window just writes that
object and the camera moves. A panel in a POP-OUT window cannot:
window.__h3dCamCtl there is a different object in a different realm, and
writing it moves nothing.
So a pane declares one field — `mirrorGlobal: '__h3dCamCtl'` — and
pane-mirror.js (main realm, where the renderers live) copies that pane's
state onto the global whenever it changes. highway_3d, keys_highway_3d
and drum_highway_3d are NOT modified and do not know panes exist.
The rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A
renderer may be holding the reference, and swapping in a new object would
leave it reading an orphan. Keys the pane doesn't set are left alone
rather than deleted — the global may carry a renderer's own bookkeeping.
Closing the pane deliberately leaves the global as-is: closing the camera
panel should not snap the camera back to a default, which is exactly what
happens today (nobody clears __h3dCamCtl).
## Manifest-declared panes
"panes": [{ "id": "camera_director", "title": "Camera Director",
"script": "panes/camera.js", "mirrorGlobal": "__h3dCamCtl" }]
Declaring a pane beats calling panes.register() from screen.js because it
becomes openable FROM THE RAIL OR THE TRAY WITHOUT THE PLUGIN'S SCREEN
EVER HAVING BEEN VISITED — core registers a stub from the manifest and
fetches the script only when the user opens it. A pane you can only reach
by first navigating to the screen it was meant to replace is not much of a
pane.
The script sets `window.feedBackPane_<id> = { mount, unmount }`, mirroring
the existing window.feedBackViz_<id> convention, and the SAME file is what
a pop-out window loads in its own realm.
`script` is validated as a relpath under the plugin's src/ and served
through the sandboxed /api/plugins/<id>/src/ route — the containment rule
`styles` already has for assets/. Traversal, absolute paths, drive letters,
backslashes and non-.js are rejected; a bad entry is dropped with a warning
rather than failing the whole plugin, because one malformed pane should not
cost the user a working plugin.
Note the projection is written TWICE — _nav_entry() and the /api/plugins
route re-project independently — so panes had to be added to both, plus the
pending branch (a pane can be opened while its plugin is still installing
deps; the script is fetched on open, not at discovery).
## docs/plugin-panes.md
The contract, and the one rule it all hangs on: mount(root, ctx) runs in a
realm that may not have the app in it. Everything comes through ctx, or the
pane works docked and silently dies popped out.
Verified: manifest validation rejects ../.., C:\, non-.js, dupes and
missing fields while passing a good entry; /api/plugins projects panes[] for
all 20 plugins. mirrorGlobal mutates the global IN PLACE — a reference held
the way _resolveFreeCam holds it sees the change, and a renderer's own field
on that object survives — both for a local write and for a write arriving
over the channel from a pop-out realm.
pytest: 2401 passed, 8 failed — all 8 reproduce on a clean main (including
the one in tests/test_plugins.py) and are unrelated.
Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
parent
d508380532
commit
254e26bb3a
213
docs/plugin-panes.md
Normal file
213
docs/plugin-panes.md
Normal file
@ -0,0 +1,213 @@
|
||||
# Detachable panes (`window.feedBack.panes`)
|
||||
|
||||
A **pane** is live UI that stays put: a mixer, a camera rig, a readout, a settings
|
||||
board. You author it once, and the host decides where it lives — docked beside the
|
||||
player, or popped out into its own OS window that remembers where you put it and
|
||||
minimizes to the system tray.
|
||||
|
||||
Panes exist because the player's rail popovers are **exclusive**: opening one closes
|
||||
the last. 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, and they survive
|
||||
song switches.
|
||||
|
||||
---
|
||||
|
||||
## The two-line version
|
||||
|
||||
If your plugin already has a dialog, give it a pop-out chip:
|
||||
|
||||
```js
|
||||
feedBack.panes.register({
|
||||
id: 'camera_director',
|
||||
title: 'Camera Director',
|
||||
icon: '🎥',
|
||||
mount(root, ctx) { root.appendChild(buildCameraUI(ctx)); }, // your existing builder
|
||||
unmount(root) { root.replaceChildren(); },
|
||||
});
|
||||
|
||||
feedBack.panes.attachChip(myDialogEl, 'camera_director');
|
||||
```
|
||||
|
||||
`attachChip()` injects **the** standard ⇱ button — same glyph, same place, same
|
||||
behaviour everywhere. Clicking it opens the pane in its host and **hides your
|
||||
dialog**, leaving a "⇲ … is popped out" stub in its place. Closing the pane
|
||||
un-hides your dialog and restores the chip.
|
||||
|
||||
**You write no show/hide logic.** Core owns it, so that every plugin's pop-out
|
||||
behaves identically — which is the entire point.
|
||||
|
||||
---
|
||||
|
||||
## The one rule
|
||||
|
||||
> **`mount(root, ctx)` runs in a realm that may not have the app in it.**
|
||||
|
||||
Docked, your pane runs in the main window with everything present. Popped out, it
|
||||
runs in a **separate JS realm** — a different window, with no `window.highway`, no
|
||||
`window.feedBack.capabilities`, no `<audio>` element, and no audio graph. Same file,
|
||||
same `mount()`, different world.
|
||||
|
||||
So: **everything your pane touches must come through `ctx`.** A pane that reaches
|
||||
for a global works docked and silently dies popped out. There is no way for core to
|
||||
paper over that, because a closure cannot cross a window boundary.
|
||||
|
||||
---
|
||||
|
||||
## `ctx`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `ctx.call(domain, command, payload)` → `Promise` | The capability bus. This is your only door into app services. Core builds the requester/origin/timeout envelope; you send a payload. Cross-realm calls have a 10 s deadline and reject with `PaneRpcTimeout`. |
|
||||
| `ctx.on(event, fn)` → `unsub` | The `feedBack` bus. Handlers get a `{ detail }` object, exactly as in the main window. Only allow-listed events are mirrored (see below). |
|
||||
| `ctx.subscribe(stream, fn)` → `unsub` | High-rate numerics: `'playhead'` → `{ t, duration, playing }`, `'meters'` → `{ master }`. |
|
||||
| `ctx.playhead()` → `number` | The current time in seconds, smoothed. **Use this, not a stream, for per-frame drawing** — see "The clock" below. |
|
||||
| `ctx.state.get(path)` / `.set(path, value)` / `.subscribe(fn)` | A dotted-path store, persisted per pane. The **main realm is authoritative**. |
|
||||
| `ctx.song()` | The current `feedBack.currentSong`, or `null`. |
|
||||
| `ctx.toast(opts)`, `ctx.close()` | |
|
||||
| `ctx.paneId`, `ctx.host`, `ctx.isRemote` | |
|
||||
|
||||
**`ctx` tracks every subscription it hands you and drops them on unmount.** You
|
||||
cannot leak a listener across a dock/undock cycle even if you try. Your `unmount()`
|
||||
only needs to clear your own DOM.
|
||||
|
||||
### Events mirrored by default
|
||||
|
||||
`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`.
|
||||
|
||||
Widen with `spec.events: ['audio-mix:fader-value-changed', …]`.
|
||||
|
||||
**`song:position-changed` is deliberately not on the list.** It fires every 250 ms —
|
||||
too coarse to animate with, too chatty to mirror across a window. Use the `playhead`
|
||||
stream or `ctx.playhead()`.
|
||||
|
||||
---
|
||||
|
||||
## The 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
|
||||
backgrounded while the user is looking at your pane. A pane that renders the raw
|
||||
broadcast stutters at 1 fps.
|
||||
|
||||
`ctx.playhead()` solves this: it extrapolates between broadcasts
|
||||
(`anchor + observedRate × elapsed`, capped at 2 s). The rate is *learned from the
|
||||
broadcasts themselves*, so it tracks the speed slider without being told, and a dead
|
||||
main window decays into a frozen clock rather than one that confidently runs away.
|
||||
|
||||
**Draw from `ctx.playhead()` in your own rAF loop. Subscribe to `'playhead'` only
|
||||
for things that change slowly** (a time readout, a progress bar).
|
||||
|
||||
---
|
||||
|
||||
## Levels, and why they are numbers
|
||||
|
||||
An `AnalyserNode` **cannot cross a window boundary.** Your pane can never hold one.
|
||||
|
||||
So core samples the analyser in the realm that owns the audio graph and ships you
|
||||
plain numbers over `ctx.subscribe('meters', …)`. The stream stays **silent** when
|
||||
there is no analyser (no stems plugin) rather than reporting zeros — so "silent" and
|
||||
"actually silent" stay distinguishable, and you can render an honest "unavailable"
|
||||
state.
|
||||
|
||||
---
|
||||
|
||||
## Declaring a pane in `plugin.json` (preferred)
|
||||
|
||||
```json
|
||||
"panes": [{
|
||||
"id": "camera_director",
|
||||
"title": "Camera Director",
|
||||
"icon": "🎥",
|
||||
"script": "panes/camera.js",
|
||||
"defaultHost": "window",
|
||||
"mirrorGlobal": "__h3dCamCtl",
|
||||
"width": 380,
|
||||
"height": 560
|
||||
}]
|
||||
```
|
||||
|
||||
Declaring it beats calling `panes.register()` from `screen.js`, because a
|
||||
manifest pane is **openable from the rail and the tray without your plugin's screen
|
||||
ever having been visited**. Core registers a stub and fetches the script only when
|
||||
the user opens it. (A pane you can only reach by first navigating to the screen it
|
||||
was meant to replace is not much of a pane.)
|
||||
|
||||
`script` is a relpath under your plugin's `src/`, served through the sandboxed
|
||||
`/api/plugins/<id>/src/…` route. It sets a factory global — the same shape the viz
|
||||
contract already uses:
|
||||
|
||||
```js
|
||||
window.feedBackPane_camera_director = {
|
||||
mount(root, ctx) { … },
|
||||
unmount(root, ctx) { … },
|
||||
};
|
||||
```
|
||||
|
||||
**This exact file is what a pop-out window loads in its own realm.** Write it to the
|
||||
one rule above.
|
||||
|
||||
---
|
||||
|
||||
## `mirrorGlobal` — for panes that drive a plain global
|
||||
|
||||
The 3D highways read their free camera from `window.__h3dCamCtl` once per frame.
|
||||
A camera panel in the main window just writes that object. A panel in a **pop-out
|
||||
window cannot** — `window.__h3dCamCtl` there is a different object in a different
|
||||
realm, and writing it moves nothing.
|
||||
|
||||
Declare `mirrorGlobal: '__h3dCamCtl'` and core copies your pane's state onto that
|
||||
global, **in the main realm, mutating the object in place** (a renderer may be
|
||||
holding the reference). You write `ctx.state.set('yaw', 0.3)`; the renderer keeps
|
||||
reading the plain global it always read; `highway_3d` is not modified and does not
|
||||
know panes exist.
|
||||
|
||||
---
|
||||
|
||||
## Rules that will bite you
|
||||
|
||||
1. **Never cache the renderer or the canvas.** `playSong()` calls `highway.stop()` →
|
||||
`destroy()`. Re-bind on `song:ready` / `highway:canvas-replaced` /
|
||||
`highway:visibility`.
|
||||
2. **Module top-level does not re-run** on screen re-entry (see
|
||||
[plugin-modules.md](plugin-modules.md)). Per-visit init belongs in `mount()`.
|
||||
3. **No per-frame `querySelector`.** Panes share the main thread with a 60 fps render
|
||||
loop. Resolve refs in `mount()`, cache them. (See the perf rules in
|
||||
[CLAUDE.md](../CLAUDE.md).)
|
||||
4. **A pane with no `script` can only ever be docked.** It exists solely as a closure
|
||||
in the main realm, and there is no honest way to move a closure across a window
|
||||
boundary — the window host declines it and the router falls back to the dock.
|
||||
5. **`localStorage` is shared with your pop-out window** (same origin). Use
|
||||
`ctx.state`, which has exactly one writer.
|
||||
6. **Persistence is on by default** (`persist: false` to opt out). State is keyed by
|
||||
pane id.
|
||||
|
||||
---
|
||||
|
||||
## Hosts
|
||||
|
||||
`panes.detach(id)` opens a pane in the best available host:
|
||||
|
||||
| host | priority | |
|
||||
|---|---|---|
|
||||
| `desktop` | 20 | A real Electron `BrowserWindow`. Remembered geometry, always-on-top, system tray. |
|
||||
| `window` | 10 | A browser pop-up (`window.open`). No tray, no remembered bounds. |
|
||||
| `dock` | 0 | The in-window card stack. **The floor** — always available, so a pane can never fail to open. |
|
||||
|
||||
You do not choose; you declare `defaultHost` and the router does the rest. A pane
|
||||
popped out in the desktop app comes back popped out on next launch; in a browser it
|
||||
comes back **docked**, because a browser blocks `window.open()` without a user
|
||||
gesture and a "pop-up blocked" toast on every page load would be worse than useless.
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
feedBack.panes.register(spec) -> unregister
|
||||
feedBack.panes.attachChip(el, paneId, { header }) -> detach
|
||||
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
|
||||
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
|
||||
```
|
||||
|
||||
`spec`: `{ id*, title, icon, mount*, unmount, script, events[], persist, initialState,
|
||||
defaultHost, mirrorGlobal, width, height }`.
|
||||
@ -277,6 +277,66 @@ def _normalize_string_list(value) -> list[str]:
|
||||
return [stripped for item in value if isinstance(item, str) and (stripped := item.strip())]
|
||||
|
||||
|
||||
def _normalize_panes(value, plugin_id: str) -> list[dict]:
|
||||
"""Validate a manifest's `panes` list (detachable panes — window.feedBack.panes).
|
||||
|
||||
Each entry: {id, title, icon?, script, defaultHost?, mirrorGlobal?, width?, height?}.
|
||||
|
||||
`script` is a relpath served through the sandboxed /api/plugins/<id>/src/
|
||||
route, so it MUST live under the plugin's src/ tree — the same containment
|
||||
rule `styles` has for assets/. A pane whose script could escape that route
|
||||
would be an arbitrary-file-read dressed up as a manifest key.
|
||||
|
||||
A bad entry is dropped with a warning rather than failing the whole plugin:
|
||||
one malformed pane should not cost the user their working plugin.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for entry in value:
|
||||
if not isinstance(entry, dict):
|
||||
log.warning("[Plugin] %s: `panes` entry is not an object, skipping", plugin_id)
|
||||
continue
|
||||
pane_id = entry.get("id")
|
||||
script = entry.get("script")
|
||||
if not isinstance(pane_id, str) or not pane_id.strip():
|
||||
log.warning("[Plugin] %s: pane is missing `id`, skipping", plugin_id)
|
||||
continue
|
||||
pane_id = pane_id.strip()
|
||||
if not isinstance(script, str) or not script.strip():
|
||||
log.warning("[Plugin] %s: pane %r is missing `script`, skipping", plugin_id, pane_id)
|
||||
continue
|
||||
script = script.strip().lstrip("/")
|
||||
# Reject anything that could climb out of src/: absolute paths, drive
|
||||
# letters, backslashes, and .. segments — the same checks settings.server_files
|
||||
# applies to its relpaths.
|
||||
if (
|
||||
"\\" in script
|
||||
or ".." in script.split("/")
|
||||
or ":" in script
|
||||
or not script.endswith(".js")
|
||||
):
|
||||
log.warning("[Plugin] %s: pane %r has an unsafe `script` %r, skipping", plugin_id, pane_id, script)
|
||||
continue
|
||||
if pane_id in seen:
|
||||
log.warning("[Plugin] %s: duplicate pane id %r, skipping", plugin_id, pane_id)
|
||||
continue
|
||||
seen.add(pane_id)
|
||||
pane = {
|
||||
"id": pane_id,
|
||||
"title": entry.get("title") if isinstance(entry.get("title"), str) else pane_id,
|
||||
"icon": entry.get("icon") if isinstance(entry.get("icon"), str) else None,
|
||||
"script": script,
|
||||
"defaultHost": entry.get("defaultHost") if entry.get("defaultHost") in ("window", "dock") else None,
|
||||
"mirrorGlobal": entry.get("mirrorGlobal") if isinstance(entry.get("mirrorGlobal"), str) else None,
|
||||
"width": entry.get("width") if isinstance(entry.get("width"), int) else None,
|
||||
"height": entry.get("height") if isinstance(entry.get("height"), int) else None,
|
||||
}
|
||||
out.append(pane)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_manifest_mapping(value) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
@ -1442,6 +1502,14 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
# client can build the asset URL without a second manifest read.
|
||||
"has_styles": bool(manifest.get("styles")),
|
||||
"styles": manifest.get("styles"),
|
||||
# Detachable panes (window.feedBack.panes). Declaring them in the
|
||||
# manifest — rather than calling panes.register() from screen.js —
|
||||
# is what lets a pane be OPENED FROM THE TRAY OR THE RAIL WITHOUT THE
|
||||
# PLUGIN'S SCREEN EVER HAVING BEEN VISITED: the host registers a stub
|
||||
# from this projection and only fetches the pane's script when the
|
||||
# user actually opens it. Validated (id + script required, script
|
||||
# sandboxed under src/) so one bad entry can't take the list down.
|
||||
"panes": _normalize_panes(manifest.get("panes"), plugin_id),
|
||||
"standards": _normalize_string_list(manifest.get("standards")),
|
||||
"capabilities": _validated_capabilities,
|
||||
"capability_validation_warnings": _capability_validation_warnings,
|
||||
@ -2164,6 +2232,11 @@ def register_plugin_api(app: FastAPI):
|
||||
# _nav_entry() at discovery and carried through graduation.
|
||||
# The `.get()` fallbacks keep stubbed test entries (which build
|
||||
# rows directly without going through _nav_entry) working.
|
||||
# Detachable panes. Key-presence check (not truthiness) like
|
||||
# `capabilities` below: _nav_entry() may legitimately have
|
||||
# validated the list down to empty, and an `or` would wrongly
|
||||
# re-derive the unvalidated manifest value.
|
||||
"panes": p["panes"] if "panes" in p else _normalize_panes((p.get("_manifest") or {}).get("panes"), p.get("id", "plugin")),
|
||||
"standards": p.get("standards") or _normalize_string_list((p.get("_manifest") or {}).get("standards")),
|
||||
"capabilities": p["capabilities"] if "capabilities" in p else _normalize_manifest_mapping((p.get("_manifest") or {}).get("capabilities")),
|
||||
"capability_validation_warnings": p.get("capability_validation_warnings", []),
|
||||
@ -2211,6 +2284,11 @@ def register_plugin_api(app: FastAPI):
|
||||
"has_tour": e.get("has_tour", False),
|
||||
"has_styles": e.get("has_styles", False),
|
||||
"styles": e.get("styles"),
|
||||
# Pending entries come from _nav_entry() too, so their panes are
|
||||
# already validated. Surfacing them here is what lets a pane be
|
||||
# opened while its plugin is still installing its dependencies —
|
||||
# the pane's script is fetched on open, not now.
|
||||
"panes": e.get("panes", []),
|
||||
# Pending entries are built from _nav_entry() too, so they
|
||||
# carry the same capability-pipelines.v1 metadata — surface it
|
||||
# so the Inspector can show still-installing plugins.
|
||||
|
||||
80
static/panes/pane-mirror.js
Normal file
80
static/panes/pane-mirror.js
Normal file
@ -0,0 +1,80 @@
|
||||
/*
|
||||
* fee[dB]ack — global mirroring for panes.
|
||||
*
|
||||
* The camera-director problem, solved without touching a single renderer.
|
||||
*
|
||||
* The 3D highways read their free-camera state from a plain global —
|
||||
* `window.__h3dCamCtl = { enabled, heightMul, distMul, yaw, pitch, panX, panY }`
|
||||
* (plugins/highway_3d/FREECAM_BRIDGE.md) — once per frame, in _resolveFreeCam().
|
||||
* A camera panel in the main window just writes that object and the camera moves.
|
||||
*
|
||||
* A panel in a POP-OUT window cannot: `window.__h3dCamCtl` there is a different
|
||||
* object in a different realm, and writing it moves nothing.
|
||||
*
|
||||
* So a pane declares one manifest field:
|
||||
*
|
||||
* panes.register({ id: 'camera_director', mirrorGlobal: '__h3dCamCtl', ... })
|
||||
*
|
||||
* and this file — which runs in the MAIN realm, where the renderers live — copies
|
||||
* that pane's state onto the global whenever it changes. The pane writes
|
||||
* ctx.state, the state store is authoritative here, and the renderers keep reading
|
||||
* the plain global they always read. highway_3d, keys_highway_3d and
|
||||
* drum_highway_3d are not modified, and do not know panes exist.
|
||||
*
|
||||
* The one rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A renderer
|
||||
* may be holding the reference (_resolveFreeCam caches it), and swapping in a new
|
||||
* object would leave it writing to — and reading from — an orphan.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
if (!panes || !bus) return;
|
||||
|
||||
// paneId -> unsubscribe
|
||||
const mirrors = new Map();
|
||||
|
||||
function _target(name) {
|
||||
// Create the global if the plugin that owns it hasn't yet (a pane can be
|
||||
// restored at boot before its renderer ever runs). Reuse it if it exists —
|
||||
// see the rule above.
|
||||
if (!window[name] || typeof window[name] !== 'object') window[name] = {};
|
||||
return window[name];
|
||||
}
|
||||
|
||||
function _apply(name, state) {
|
||||
const target = _target(name);
|
||||
// Copy the pane's whole state root onto the global. Keys the pane doesn't
|
||||
// set are left alone rather than deleted — the global may carry fields the
|
||||
// pane knows nothing about (a renderer's own bookkeeping), and clearing
|
||||
// them would be a silent, spooky breakage.
|
||||
Object.keys(state).forEach((k) => { target[k] = state[k]; });
|
||||
}
|
||||
|
||||
bus.on('panes:opened', (e) => {
|
||||
const id = e.detail && e.detail.id;
|
||||
const spec = panes.get(id);
|
||||
if (!spec || !spec.mirrorGlobal || mirrors.has(id)) return;
|
||||
const entry = panes._entry(id);
|
||||
if (!entry) return;
|
||||
|
||||
// Sync once on open: a pane's persisted state is the user's last camera,
|
||||
// and it should take effect the moment the pane exists — not on their next
|
||||
// nudge of a slider.
|
||||
_apply(spec.mirrorGlobal, entry.state.all());
|
||||
|
||||
mirrors.set(id, entry.state.subscribe((all) => _apply(spec.mirrorGlobal, all)));
|
||||
});
|
||||
|
||||
bus.on('panes:closed', (e) => {
|
||||
const id = e.detail && e.detail.id;
|
||||
const unsub = mirrors.get(id);
|
||||
if (!unsub) return;
|
||||
unsub();
|
||||
mirrors.delete(id);
|
||||
// The global is deliberately LEFT AS IT IS. Closing the camera panel should
|
||||
// not snap the camera back to a default — that is exactly what happens
|
||||
// today (nobody clears __h3dCamCtl), and it is the behaviour users expect.
|
||||
});
|
||||
})();
|
||||
122
static/panes/pane-plugins.js
Normal file
122
static/panes/pane-plugins.js
Normal file
@ -0,0 +1,122 @@
|
||||
/*
|
||||
* fee[dB]ack — manifest-declared plugin panes.
|
||||
*
|
||||
* A plugin can ship a pane by declaring it in plugin.json, with no code in
|
||||
* screen.js at all:
|
||||
*
|
||||
* "panes": [{
|
||||
* "id": "camera_director",
|
||||
* "title": "Camera Director",
|
||||
* "icon": "🎥",
|
||||
* "script": "panes/camera.js",
|
||||
* "mirrorGlobal": "__h3dCamCtl"
|
||||
* }]
|
||||
*
|
||||
* The point of declaring it rather than calling panes.register() is that the
|
||||
* pane becomes openable — from the rail, from the tray — WITHOUT THE PLUGIN'S
|
||||
* SCREEN EVER HAVING BEEN VISITED. We register a stub from the manifest and
|
||||
* fetch the script only when the user actually opens it. A pane you can only
|
||||
* reach by first navigating to the screen it was supposed to replace is not
|
||||
* much of a pane.
|
||||
*
|
||||
* The script sets a factory global, the same shape the viz contract already uses
|
||||
* (`window.feedBackViz_<id>`):
|
||||
*
|
||||
* window.feedBackPane_camera_director = {
|
||||
* mount(root, ctx) { ... },
|
||||
* unmount(root, ctx) { ... },
|
||||
* };
|
||||
*
|
||||
* It is loaded from the sandboxed /api/plugins/<plugin>/src/<script> route, and
|
||||
* the SAME file is what a pop-out window loads in its own realm — so it must not
|
||||
* assume the app is there. `ctx` is everything it gets. See docs/plugin-panes.md.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes) return;
|
||||
|
||||
// Loaded factories, so re-opening a pane doesn't re-fetch its script.
|
||||
const loading = new Map(); // paneId -> Promise<factory>
|
||||
|
||||
function _factory(paneId) {
|
||||
return window['feedBackPane_' + paneId] || null;
|
||||
}
|
||||
|
||||
function _load(paneId, url) {
|
||||
const already = _factory(paneId);
|
||||
if (already) return Promise.resolve(already);
|
||||
if (loading.has(paneId)) return loading.get(paneId);
|
||||
|
||||
const p = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = () => {
|
||||
const f = _factory(paneId);
|
||||
if (f) resolve(f);
|
||||
else reject(new Error('pane script loaded but set no window.feedBackPane_' + paneId));
|
||||
};
|
||||
s.onerror = () => reject(new Error('failed to load pane script: ' + url));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
loading.set(paneId, p);
|
||||
p.catch(() => loading.delete(paneId)); // let a transient failure be retried
|
||||
return p;
|
||||
}
|
||||
|
||||
function register(plugin, def) {
|
||||
const url = '/api/plugins/' + plugin.id + '/src/' + def.script;
|
||||
|
||||
panes.register({
|
||||
id: def.id,
|
||||
title: def.title || def.id,
|
||||
icon: def.icon || '▣',
|
||||
// The pop-out realm loads exactly the same file.
|
||||
script: url,
|
||||
defaultHost: def.defaultHost || 'window',
|
||||
mirrorGlobal: def.mirrorGlobal || null,
|
||||
width: def.width || undefined,
|
||||
height: def.height || undefined,
|
||||
|
||||
// The stub. Docked, the script is fetched on first open and its
|
||||
// factory takes over from here; the root is already on screen, so a
|
||||
// slow fetch shows an empty card rather than blocking the click.
|
||||
mount(root, ctx) {
|
||||
_load(def.id, url).then((factory) => {
|
||||
if (!root.isConnected) return; // closed again while we were fetching
|
||||
factory.mount(root, ctx);
|
||||
root.__fbPaneFactory = factory;
|
||||
}).catch((err) => {
|
||||
console.error('[panes]', def.id, err);
|
||||
const oops = document.createElement('div');
|
||||
oops.className = 'fb-pane-dim';
|
||||
oops.textContent = 'This pane failed to load.';
|
||||
root.appendChild(oops);
|
||||
});
|
||||
},
|
||||
|
||||
unmount(root, ctx) {
|
||||
const factory = root.__fbPaneFactory;
|
||||
delete root.__fbPaneFactory;
|
||||
// Only the factory that actually mounted gets to unmount. A pane
|
||||
// closed before its script arrived never mounted, and calling
|
||||
// unmount() on it would hand the plugin a root it has never seen.
|
||||
if (factory && typeof factory.unmount === 'function') {
|
||||
try { factory.unmount(root, ctx); } catch (e) { console.error('[panes]', def.id, 'unmount threw', e); }
|
||||
}
|
||||
root.replaceChildren();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/api/plugins')
|
||||
.then((r) => r.json())
|
||||
.then((list) => {
|
||||
(Array.isArray(list) ? list : []).forEach((plugin) => {
|
||||
if (plugin.enabled === false || !Array.isArray(plugin.panes)) return;
|
||||
plugin.panes.forEach((def) => register(plugin, def));
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error('[panes] could not read the plugin list', err));
|
||||
})();
|
||||
@ -126,11 +126,22 @@
|
||||
});
|
||||
|
||||
loadScript(snap.spec.script).then(() => {
|
||||
if (!captured) { fail('The pane script loaded but registered nothing.'); return; }
|
||||
// Two ways a script can hand us a pane, and the same file must work in
|
||||
// both realms:
|
||||
// - a plugin pane sets window.feedBackPane_<id> (the factory global,
|
||||
// mirroring the existing window.feedBackViz_<id> contract), because
|
||||
// the main realm loads it lazily and must not have to re-register;
|
||||
// - a core built-in calls feedBack.panes.register(), which our shim
|
||||
// above captured.
|
||||
const pane = window['feedBackPane_' + paneId] || captured;
|
||||
if (!pane || typeof pane.mount !== 'function') {
|
||||
fail('The pane script loaded but provided no pane.');
|
||||
return;
|
||||
}
|
||||
statusEl.hidden = true;
|
||||
rootEl.hidden = false;
|
||||
try {
|
||||
captured.mount(rootEl, ctx);
|
||||
pane.mount(rootEl, ctx);
|
||||
mounted = true;
|
||||
} catch (err) {
|
||||
console.error('[pane] mount() threw', err);
|
||||
|
||||
@ -1333,9 +1333,13 @@
|
||||
<script defer src="/static/panes/pane-desktop-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-mirror.js"></script>
|
||||
<script defer src="/static/panes/pane-launcher.js"></script>
|
||||
<script defer src="/static/panes/builtin/now-playing.js"></script>
|
||||
<script defer src="/static/panes/builtin/mixer-pane.js"></script>
|
||||
<!-- Last: registers panes declared in plugin manifests, so a plugin's pane is
|
||||
openable from the rail or the tray without its screen ever being visited. -->
|
||||
<script defer src="/static/panes/pane-plugins.js"></script>
|
||||
<script>
|
||||
// Navbar scroll effect
|
||||
window.addEventListener('scroll', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user