Files
feedBack-desktop/src/main/pane-hosts.ts
T
topkoa 0878ae2682 feat(panes): a pane window can never go behind the app
A pane is a control surface for the thing you are looking at. Clicking the
highway to play — the single most common thing anyone does here — raises the
main window, and a plain sibling window slides straight behind it. You would
fish the mixer back out from behind the game every time you touched the game.
That is not a pop-out, it is a hiding place.

Parent each pane window to the main window. That is the precise amount of "in
front": the pane always floats above fee[dB]ack, and behaves like any other
window against everything else.

Deliberately NOT setAlwaysOnTop. That would put a pane above the user's browser
and editor too — a surprising thing to inflict on someone for opening a mixer.
alwaysOnTop still composes on top of this for a pane the user explicitly wants
above everything.

The trade, accepted: the OS ties parent and child together, so minimizing
fee[dB]ack hides its panes and restoring brings them back. That is what a
companion window should do.

Failure is non-fatal — a pane that can be buried is still a working pane.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 22:32:30 -04:00

324 lines
16 KiB
TypeScript

// Pane pop-out windows.
//
// feedBack core has a pane system (window.feedBack.panes): a plugin's panel — a
// mixer, a camera rig, a readout — popped out of the app into its own window and
// left there. Here it gets the desktop treatment: remembered geometry, off the
// taskbar, minimize-to-tray, and a tray menu that lists every pane (pane-tray.ts).
//
// READ THIS BEFORE "TIDYING UP" THE WINDOW CREATION.
//
// We do NOT create these windows. The renderer opens them with window.open(), and
// Electron's setWindowOpenHandler (main.ts) turns that into a real BrowserWindow
// for us. That is not an accident and it is not laziness:
//
// The pane's element is MOVED into the pop-out window — the actual DOM node,
// adopted across same-origin documents, so it keeps its listeners and its
// closures and goes on running the plugin's own code. To adopt it, the renderer
// needs a handle on the new window's document. A window WE created in the main
// process gives it no such handle. Create the window here and the whole feature
// collapses back into "reimplement the panel and sync it over IPC".
//
// So the renderer opens the window, names its frame `fbpane-<paneId>`, and we
// recognise it in main.ts's did-create-window and attach the OS behaviour. The
// renderer keeps the DOM link; we supply the window manners.
import { BrowserWindow, ipcMain, screen } from 'electron';
import { IPC_PANE_SYNC } from './ipc-channels';
import { sanitizeWindowBounds, type WindowSizing } from './window-bounds';
import { getDesktopConfig, setDesktopConfig, type SavedPaneWindow } from './soundfont-manager';
import { setTrayPanes, type TrayPane } from './pane-tray';
// The renderer names the frame `fbpane-<paneId>`. Keep in sync with
// static/panes/pane-window-host.js.
const FRAME_PREFIX = 'fbpane-';
// A pane window is small by nature. The main window's 800x600 floor would inflate
// one threefold, which is why sanitizeWindowBounds takes sizing now.
const PANE_SIZING: WindowSizing = {
minWidth: 240,
minHeight: 180,
defaultWidth: 380,
defaultHeight: 560,
};
const windows = new Map<string, BrowserWindow>();
let getMainWindow: () => BrowserWindow | null = () => null;
// ── Geometry ────────────────────────────────────────────────────────────────
// The config file is untrusted: hand-edited, corrupt, or written by a build that
// disagrees with this one. It is read in the MAIN process, where a TypeError is not
// a bad pane — it is the app failing to start.
function savedPaneMap(): Record<string, unknown> {
const map = getDesktopConfig().paneWindows;
if (!map || typeof map !== 'object' || Array.isArray(map)) return {};
return map as Record<string, unknown>;
}
function savedFor(paneId: string): SavedPaneWindow {
const saved = savedPaneMap();
// Own-property check: a polluted or hand-edited config would otherwise hand back
// a value off the prototype chain for a pane that was never saved at all.
if (!Object.prototype.hasOwnProperty.call(saved, paneId)) return {};
const entry = saved[paneId];
// `{"camera_director": null}` passes the own-property check and then explodes on
// `saved.bounds`. Anything that is not an object degrades to "nothing saved",
// which is exactly what an unreadable entry means.
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return {};
return entry as SavedPaneWindow;
}
// A pane id reaches us from the RENDERER (it is the tail of the frame name a
// window.open() supplied), and it is used as a key in the persisted map. So it is
// untrusted input in key position: `__proto__` and friends are not ids, they are a
// way to mutate Object.prototype from a plugin.
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function isUnsafePaneId(paneId: string): boolean {
return UNSAFE_KEYS.has(paneId);
}
function persist(paneId: string, patch: SavedPaneWindow): void {
if (isUnsafePaneId(paneId)) return;
try {
// setDesktopConfig merges shallowly, so paneWindows must be
// read-modify-written or one pane's save would drop every other pane's.
//
// Built on a null-prototype object: whatever is in the config file (hand
// edited, corrupt, or written by an older build) cannot smuggle a prototype
// into a map we then write keys onto.
const all: Record<string, SavedPaneWindow> = Object.create(null);
const saved = savedPaneMap();
for (const key of Object.keys(saved)) {
if (isUnsafePaneId(key)) continue;
const entry = saved[key];
// Don't carry a corrupt entry forward. Spreading `null` into the patch
// below would be silently fine; writing it back out would keep a value
// that crashes savedFor() on the next launch forever.
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
all[key] = entry as SavedPaneWindow;
}
all[paneId] = { ...(all[paneId] ?? {}), ...patch };
setDesktopConfig({ paneWindows: { ...all } });
} catch (err) {
console.warn(`[panes] failed to persist geometry for ${paneId}:`, err);
}
}
// Everything we remember about a pane window, read in one place so the debounced
// save, the flush-on-close and the flush-on-quit can never disagree about what
// "remembered" means. alwaysOnTop is in here because it was being RESTORED and never
// written — a setting that could only ever be turned on by hand-editing the config.
function snapshot(win: BrowserWindow): SavedPaneWindow {
return {
bounds: { ...win.getNormalBounds(), maximized: false },
alwaysOnTop: win.isAlwaysOnTop(),
};
}
// setDesktopConfig is writeFileSync + renameSync. Wiring that straight to 'moved'
// and 'resized' means a synchronous disk write for every frame of a drag — on
// macOS, dozens per second, in the main process, where they block everything else.
// Write once the gesture settles instead.
const GEOMETRY_SAVE_DEBOUNCE_MS = 400;
const saveTimers = new Map<string, NodeJS.Timeout>();
function persistSoon(paneId: string, patch: () => SavedPaneWindow | null): void {
clearTimeout(saveTimers.get(paneId));
saveTimers.set(paneId, setTimeout(() => {
saveTimers.delete(paneId);
const value = patch();
if (value) persist(paneId, value);
}, GEOMETRY_SAVE_DEBOUNCE_MS));
}
// Debouncing means the last move can still be in flight when the window goes. Write
// it out NOW, while the window is alive enough to be measured — otherwise a user who
// nudges a pane and closes it a moment later loses the position they just chose,
// which is exactly the thing remembered geometry exists to prevent.
function flushGeometry(win: BrowserWindow, paneId: string): void {
const timer = saveTimers.get(paneId);
if (timer) { clearTimeout(timer); saveTimers.delete(paneId); }
if (win.isDestroyed()) return;
persist(paneId, snapshot(win));
}
// ── Adoption ────────────────────────────────────────────────────────────────
export function paneIdFromFrameName(frameName: string): string | null {
if (!frameName || !frameName.startsWith(FRAME_PREFIX)) return null;
const id = frameName.slice(FRAME_PREFIX.length);
return id ? id : null;
}
// Called from main.ts's did-create-window when the renderer pops a pane out.
export function adoptPaneWindow(win: BrowserWindow, paneId: string): void {
windows.set(paneId, win);
const saved = savedFor(paneId);
const restored = sanitizeWindowBounds(
saved.bounds,
screen.getAllDisplays().map((d) => d.workArea),
// The size window.open() asked for is the fallback for a pane that has
// never been opened before; the saved bounds win once it has.
{ ...PANE_SIZING, defaultWidth: win.getBounds().width, defaultHeight: win.getBounds().height },
);
if (restored.x !== undefined && restored.y !== undefined) {
win.setBounds({ x: restored.x, y: restored.y, width: restored.width, height: restored.height });
} else {
win.setSize(restored.width, restored.height);
}
win.setMinimumSize(PANE_SIZING.minWidth, PANE_SIZING.minHeight);
if (saved.alwaysOnTop === true) win.setAlwaysOnTop(true);
// A PANE CAN NEVER GO BEHIND THE APP.
//
// A pane is a control surface for the thing you are looking at. Clicking the
// highway to play — the single most common thing anyone does here — raises the
// main window, and a plain sibling window would slide straight behind it. You
// would have to fish the mixer back out from behind the game every time you
// touched the game. That is not a pop-out, it is a hiding place.
//
// Parenting to the main window (rather than setAlwaysOnTop) is the precise
// amount of "in front": the pane always floats above fee[dB]ack, and behaves
// like any other window against everything else. Always-on-top would put it
// above the user's browser and editor too — a surprising thing to inflict on
// someone for opening a mixer.
//
// alwaysOnTop remains available on top of this for a pane the user explicitly
// wants above EVERYTHING; the two compose.
const main = getMainWindow();
if (main && !main.isDestroyed()) {
try {
win.setParentWindow(main);
} catch (err) {
// Not fatal: a pane that can be buried is still a working pane.
console.warn(`[panes] could not parent ${paneId} to the main window:`, err);
}
}
// A pane is a companion to the app, not an entry to it: keep it off the taskbar
// so it never masquerades as a second fee[dB]ack.
win.setSkipTaskbar(true);
// Persist on move/resize, not only on close — a pane window can outlive the app
// in a crash, and the whole point of remembering geometry is that you never
// place it twice.
const save = (): void => {
persistSoon(paneId, () => (win.isDestroyed() ? null : snapshot(win)));
};
win.on('moved', save);
win.on('resized', save);
// Minimize sends a pane to the tray, not the taskbar. Panes are small and
// numerous; a taskbar full of them is noise, and the tray already lists them.
// Electron's 'minimize' is not cancellable here (the listener takes no event),
// so we hide right after rather than preventing it — and the window is
// skipTaskbar, so there is no animation to see.
win.on('minimize', () => {
win.hide();
refreshTray();
});
// 'close' fires while the window still exists; 'closed' after it is gone. The
// final geometry can only be read from the former.
win.on('close', () => flushGeometry(win, paneId));
win.on('closed', () => {
windows.delete(paneId);
refreshTray();
// No IPC needed to tell the renderer: it opened this window itself and holds
// the WindowProxy, so it already knows — and it has to, because its element
// is inside and must be brought home.
});
refreshTray();
}
export function closeAllPanes(): void {
// destroy() does NOT fire 'close', so the flush wired to that event never runs on
// this path — and the debounced save may still be pending. Move a pane, quit two
// seconds later, and its position would be gone. Flush every pane first.
windows.forEach((win, paneId) => flushGeometry(win, paneId));
// Called when the main window goes. A pane window holds a DOM node belonging to
// the main window's document — with the main window gone there is nothing left
// to dock it back into. And worse: a pane HIDDEN in the tray is still an open
// window, so leaving one behind would stop `window-all-closed` from ever firing
// and the app would linger as an invisible process.
Array.from(windows.values()).forEach((win) => { if (!win.isDestroyed()) win.destroy(); });
windows.clear();
}
// ── Tray ────────────────────────────────────────────────────────────────────
// The renderer's last known pane registry. The tray menu is a VIEW of it, never a
// second copy — main has no idea what a pane contains, and does not need one.
let lastSync: TrayPane[] = [];
function refreshTray(): void {
setTrayPanes(lastSync.map((p) => {
const win = windows.get(p.id);
return {
...p,
// "open", to the tray, means "has a visible window". A pane docked inside
// the main window is not something the tray can usefully show or hide.
open: !!win && !win.isDestroyed() && win.isVisible(),
};
}));
}
// A pane is sent to the tray by MINIMIZING it and then hiding it — and hiding a
// minimized window does not un-minimize it. So show() alone would restore a window
// that is still minimized: present, but not on screen, which reads as the tray
// being broken. Always restore first.
function reveal(win: BrowserWindow): void {
if (win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
win.show();
}
export function togglePaneWindow(paneId: string): boolean {
const win = windows.get(paneId);
if (!win || win.isDestroyed()) return false; // not open → only the renderer can open it
if (win.isVisible() && !win.isMinimized()) win.hide(); else reveal(win);
refreshTray();
return true;
}
export function showAllPaneWindows(): void {
windows.forEach((win) => { if (!win.isDestroyed()) reveal(win); });
refreshTray();
}
export function hideAllPaneWindows(): void {
windows.forEach((win) => { if (!win.isDestroyed() && win.isVisible()) win.hide(); });
refreshTray();
}
// ── Wiring ──────────────────────────────────────────────────────────────────
export function initPaneHosts(deps: { getMainWindow: () => BrowserWindow | null }): void {
getMainWindow = deps.getMainWindow;
// The renderer pushes its registry whenever a pane is registered, opened or
// closed, so the tray can list panes it otherwise knows nothing about.
// Fire-and-forget: the tray is a view of the renderer's truth.
//
// Only the MAIN window's truth, though. Pane windows are same-origin top-level
// frames, so preload.ts's isMainFrame gate gives them the bridge too — meaning a
// pane window (or any allowed pop-up) could send pane:sync and overwrite the
// tray's registry, most simply by pushing an empty list and emptying the menu.
// Only one renderer owns the pane registry; accept it from that one only.
ipcMain.on(IPC_PANE_SYNC, (event, panes: unknown) => {
const main = getMainWindow();
if (!main || main.isDestroyed() || event.sender !== main.webContents) {
console.warn('[panes] ignoring pane:sync from a webContents that is not the main window');
return;
}
lastSync = Array.isArray(panes)
? panes.filter((p): p is TrayPane => !!p && typeof p.id === 'string' && typeof p.title === 'string')
: [];
refreshTray();
});
}