feat(panes)!: move the real element, instead of rebuilding it

The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.

What a user wants from "pop this out" is the thing they popped out.

So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.

The plugin's side collapses to two lines:

    feedBack.panes.register({ id, title, element: () => panelEl });
    feedBack.panes.attachChip(panelEl, id);

and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.

Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.

Consequences worth knowing:

- The window MUST be opened by the renderer with window.open(), not by the
  desktop's main process: a window we did not open gives this realm no handle
  to its document, and without the handle there is nothing to adopt into.
  Electron turns the same-origin window.open() into a real BrowserWindow
  anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
  live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
  A plugin panel is nearly always a fixed overlay pinned to a corner of the
  app; alone in a 380px window that positioning is nonsense. Colours, borders,
  padding, fonts and the panel's own internal layout are untouched — the whole
  promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
  among its siblings.
- The plugin's code still runs in the main window. So a document.body
  .appendChild() inside a panel (a tooltip, a popover) lands in the main
  window, not the pane — anchor to the panel instead. And a continuously
  animating panel may run slowly while the main window is backgrounded, since
  its rAF lives there. Both documented.

Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
topkoa
2026-07-12 18:12:46 -04:00
parent 330995588c
commit 188bdaa837
18 changed files with 518 additions and 2295 deletions
-78
View File
@@ -277,66 +277,6 @@ 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 {}
@@ -1502,14 +1442,6 @@ 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,
@@ -2232,11 +2164,6 @@ 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", []),
@@ -2284,11 +2211,6 @@ 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.