fix(panes): break the tray/host import cycle; debounce saves; restore before show

Five findings from CodeRabbit on #103.

1. CIRCULAR IMPORT. pane-tray imported pane-hosts while pane-hosts imported
   pane-tray. In the main process that is not a style question: whichever module
   loses the load race sees the other's exports half-initialised, and it fails at
   whatever moment the graph happens to resolve in — which is to say, not on your
   machine.

   One direction only now: pane-hosts → pane-tray. What the tray needs from the
   host (toggle/showAll/hideAll/hasWindow/getMainWindow) is INJECTED through
   initTray(), wired in main.ts.

2. SYNCHRONOUS DISK WRITES ON EVERY DRAG FRAME. `save()` was wired straight to
   'moved'/'resized', and setDesktopConfig is writeFileSync + renameSync. On
   macOS that is dozens of blocking writes per second, in the main process,
   while the user drags.

   Debounced to 400ms — and then flushed on 'close', because a debounce that
   drops the last move is worse than no debounce: nudge a pane, close it a moment
   later, and you would lose the position you just chose, which is the exact thing
   remembered geometry exists to prevent. 'close' (not 'closed') because the
   window has to still exist to be measured.

3. A MINIMIZED PANE COULD NOT BE BROUGHT BACK. Panes go to the tray by being
   minimized and then hidden — and hiding a minimized window does not un-minimize
   it. So show() from the tray restored a window that was still minimized:
   present, but not on screen. Which reads as the tray being broken. Everything
   now goes through reveal(), which restores first.

4. PROTOTYPE POLLUTION VIA PANE ID. A pane id arrives from the RENDERER (it is
   the tail of the frame name window.open() supplied) and is used as a KEY in the
   persisted paneWindows map. `__proto__` is not an id, it is a way to mutate
   Object.prototype from a plugin. Rejected on write, the map is rebuilt on a
   null-prototype object, and reads are own-property checks — otherwise a polluted
   or hand-edited config hands back geometry for a pane that was never saved.

5. Removed getMainWindowRef(), exported and referenced nowhere.

Not applicable: the finding about req.width/req.height/req.title reaching
BrowserWindow unvalidated. That was the `pane:open` IPC, which no longer exists —
main does not create pane windows at all now (the renderer must, so it can adopt
its element into them). The sizes now come from the window Electron already made.

Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
topkoa
2026-07-12 21:05:11 -04:00
parent 938ddded60
commit 3d9e8481ea
3 changed files with 1821 additions and 1736 deletions
+1431 -1419
View File
File diff suppressed because it is too large Load Diff
+255 -197
View File
@@ -1,197 +1,255 @@
// Pane pop-out windows. // Pane pop-out windows.
// //
// feedBack core has a pane system (window.feedBack.panes): a plugin's panel — a // 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 // 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 // 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). // taskbar, minimize-to-tray, and a tray menu that lists every pane (pane-tray.ts).
// //
// READ THIS BEFORE "TIDYING UP" THE WINDOW CREATION. // READ THIS BEFORE "TIDYING UP" THE WINDOW CREATION.
// //
// We do NOT create these windows. The renderer opens them with window.open(), and // 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 // Electron's setWindowOpenHandler (main.ts) turns that into a real BrowserWindow
// for us. That is not an accident and it is not laziness: // 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, // 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 // 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 // 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 // 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 // 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". // collapses back into "reimplement the panel and sync it over IPC".
// //
// So the renderer opens the window, names its frame `fbpane-<paneId>`, and we // 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 // 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. // renderer keeps the DOM link; we supply the window manners.
import { BrowserWindow, ipcMain, screen } from 'electron'; import { BrowserWindow, ipcMain, screen } from 'electron';
import { IPC_PANE_SYNC } from './ipc-channels'; import { IPC_PANE_SYNC } from './ipc-channels';
import { sanitizeWindowBounds, type WindowSizing } from './window-bounds'; import { sanitizeWindowBounds, type WindowSizing } from './window-bounds';
import { getDesktopConfig, setDesktopConfig, type SavedPaneWindow } from './soundfont-manager'; import { getDesktopConfig, setDesktopConfig, type SavedPaneWindow } from './soundfont-manager';
import { setTrayPanes, type TrayPane } from './pane-tray'; import { setTrayPanes, type TrayPane } from './pane-tray';
// The renderer names the frame `fbpane-<paneId>`. Keep in sync with // The renderer names the frame `fbpane-<paneId>`. Keep in sync with
// static/panes/pane-window-host.js. // static/panes/pane-window-host.js.
const FRAME_PREFIX = 'fbpane-'; const FRAME_PREFIX = 'fbpane-';
// A pane window is small by nature. The main window's 800x600 floor would inflate // A pane window is small by nature. The main window's 800x600 floor would inflate
// one threefold, which is why sanitizeWindowBounds takes sizing now. // one threefold, which is why sanitizeWindowBounds takes sizing now.
const PANE_SIZING: WindowSizing = { const PANE_SIZING: WindowSizing = {
minWidth: 240, minWidth: 240,
minHeight: 180, minHeight: 180,
defaultWidth: 380, defaultWidth: 380,
defaultHeight: 560, defaultHeight: 560,
}; };
const windows = new Map<string, BrowserWindow>(); const windows = new Map<string, BrowserWindow>();
let getMainWindow: () => BrowserWindow | null = () => null; let getMainWindow: () => BrowserWindow | null = () => null;
// ── Geometry ──────────────────────────────────────────────────────────────── // ── Geometry ────────────────────────────────────────────────────────────────
function savedFor(paneId: string): SavedPaneWindow { function savedFor(paneId: string): SavedPaneWindow {
return getDesktopConfig().paneWindows?.[paneId] ?? {}; const saved = getDesktopConfig().paneWindows ?? {};
} // 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.
function persist(paneId: string, patch: SavedPaneWindow): void { return Object.prototype.hasOwnProperty.call(saved, paneId) ? saved[paneId] : {};
try { }
// setDesktopConfig merges shallowly, so paneWindows must be
// read-modify-written or one pane's save would drop every other pane's. // A pane id reaches us from the RENDERER (it is the tail of the frame name a
const all = { ...(getDesktopConfig().paneWindows ?? {}) }; // window.open() supplied), and it is used as a key in the persisted map. So it is
all[paneId] = { ...(all[paneId] ?? {}), ...patch }; // untrusted input in key position: `__proto__` and friends are not ids, they are a
setDesktopConfig({ paneWindows: all }); // way to mutate Object.prototype from a plugin.
} catch (err) { const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
console.warn(`[panes] failed to persist geometry for ${paneId}:`, err); function isUnsafePaneId(paneId: string): boolean {
} return UNSAFE_KEYS.has(paneId);
} }
// ── Adoption ──────────────────────────────────────────────────────────────── function persist(paneId: string, patch: SavedPaneWindow): void {
if (isUnsafePaneId(paneId)) return;
export function paneIdFromFrameName(frameName: string): string | null { try {
if (!frameName || !frameName.startsWith(FRAME_PREFIX)) return null; // setDesktopConfig merges shallowly, so paneWindows must be
const id = frameName.slice(FRAME_PREFIX.length); // read-modify-written or one pane's save would drop every other pane's.
return id ? id : null; //
} // 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
// Called from main.ts's did-create-window when the renderer pops a pane out. // into a map we then write keys onto.
export function adoptPaneWindow(win: BrowserWindow, paneId: string): void { const all: Record<string, SavedPaneWindow> = Object.create(null);
windows.set(paneId, win); const saved = getDesktopConfig().paneWindows ?? {};
for (const key of Object.keys(saved)) {
const saved = savedFor(paneId); if (!isUnsafePaneId(key)) all[key] = saved[key];
const restored = sanitizeWindowBounds( }
saved.bounds, all[paneId] = { ...(all[paneId] ?? {}), ...patch };
screen.getAllDisplays().map((d) => d.workArea), setDesktopConfig({ paneWindows: { ...all } });
// The size window.open() asked for is the fallback for a pane that has } catch (err) {
// never been opened before; the saved bounds win once it has. console.warn(`[panes] failed to persist geometry for ${paneId}:`, err);
{ ...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 }); // setDesktopConfig is writeFileSync + renameSync. Wiring that straight to 'moved'
} else { // and 'resized' means a synchronous disk write for every frame of a drag — on
win.setSize(restored.width, restored.height); // macOS, dozens per second, in the main process, where they block everything else.
} // Write once the gesture settles instead.
win.setMinimumSize(PANE_SIZING.minWidth, PANE_SIZING.minHeight); const GEOMETRY_SAVE_DEBOUNCE_MS = 400;
if (saved.alwaysOnTop === true) win.setAlwaysOnTop(true); const saveTimers = new Map<string, NodeJS.Timeout>();
// A pane is a companion to the app, not an entry to it: keep it off the taskbar function persistSoon(paneId: string, patch: () => SavedPaneWindow | null): void {
// so it never masquerades as a second fee[dB]ack. clearTimeout(saveTimers.get(paneId));
win.setSkipTaskbar(true); saveTimers.set(paneId, setTimeout(() => {
saveTimers.delete(paneId);
// Persist on move/resize, not only on close — a pane window can outlive the app const value = patch();
// in a crash, and the whole point of remembering geometry is that you never if (value) persist(paneId, value);
// place it twice. }, GEOMETRY_SAVE_DEBOUNCE_MS));
const save = (): void => { }
if (win.isDestroyed()) return;
persist(paneId, { bounds: { ...win.getNormalBounds(), maximized: false } }); // 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
win.on('moved', save); // nudges a pane and closes it a moment later loses the position they just chose,
win.on('resized', save); // which is exactly the thing remembered geometry exists to prevent.
function flushGeometry(win: BrowserWindow, paneId: string): void {
// Minimize sends a pane to the tray, not the taskbar. Panes are small and const timer = saveTimers.get(paneId);
// numerous; a taskbar full of them is noise, and the tray already lists them. if (timer) { clearTimeout(timer); saveTimers.delete(paneId); }
// Electron's 'minimize' is not cancellable here (the listener takes no event), if (win.isDestroyed()) return;
// so we hide right after rather than preventing it — and the window is persist(paneId, { bounds: { ...win.getNormalBounds(), maximized: false } });
// skipTaskbar, so there is no animation to see. }
win.on('minimize', () => {
win.hide(); // ── Adoption ────────────────────────────────────────────────────────────────
refreshTray();
}); export function paneIdFromFrameName(frameName: string): string | null {
if (!frameName || !frameName.startsWith(FRAME_PREFIX)) return null;
win.on('closed', () => { const id = frameName.slice(FRAME_PREFIX.length);
windows.delete(paneId); return id ? id : null;
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 // Called from main.ts's did-create-window when the renderer pops a pane out.
// is inside and must be brought home. export function adoptPaneWindow(win: BrowserWindow, paneId: string): void {
}); windows.set(paneId, win);
refreshTray(); const saved = savedFor(paneId);
} const restored = sanitizeWindowBounds(
saved.bounds,
export function closeAllPanes(): void { screen.getAllDisplays().map((d) => d.workArea),
// Called when the main window goes. A pane window holds a DOM node belonging to // The size window.open() asked for is the fallback for a pane that has
// the main window's document — with the main window gone there is nothing left // never been opened before; the saved bounds win once it has.
// to dock it back into. And worse: a pane HIDDEN in the tray is still an open { ...PANE_SIZING, defaultWidth: win.getBounds().width, defaultHeight: win.getBounds().height },
// window, so leaving one behind would stop `window-all-closed` from ever firing );
// and the app would linger as an invisible process. if (restored.x !== undefined && restored.y !== undefined) {
Array.from(windows.values()).forEach((win) => { if (!win.isDestroyed()) win.destroy(); }); win.setBounds({ x: restored.x, y: restored.y, width: restored.width, height: restored.height });
windows.clear(); } else {
} win.setSize(restored.width, restored.height);
}
// ── Tray ──────────────────────────────────────────────────────────────────── win.setMinimumSize(PANE_SIZING.minWidth, PANE_SIZING.minHeight);
if (saved.alwaysOnTop === true) win.setAlwaysOnTop(true);
// 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. // A pane is a companion to the app, not an entry to it: keep it off the taskbar
let lastSync: TrayPane[] = []; // so it never masquerades as a second fee[dB]ack.
win.setSkipTaskbar(true);
function refreshTray(): void {
setTrayPanes(lastSync.map((p) => { // Persist on move/resize, not only on close — a pane window can outlive the app
const win = windows.get(p.id); // in a crash, and the whole point of remembering geometry is that you never
return { // place it twice.
...p, const save = (): void => {
// "open", to the tray, means "has a visible window". A pane docked inside persistSoon(paneId, () =>
// the main window is not something the tray can usefully show or hide. win.isDestroyed() ? null : { bounds: { ...win.getNormalBounds(), maximized: false } });
open: !!win && !win.isDestroyed() && win.isVisible(), };
}; win.on('moved', save);
})); win.on('resized', save);
}
// Minimize sends a pane to the tray, not the taskbar. Panes are small and
export function togglePaneWindow(paneId: string): boolean { // numerous; a taskbar full of them is noise, and the tray already lists them.
const win = windows.get(paneId); // Electron's 'minimize' is not cancellable here (the listener takes no event),
if (!win || win.isDestroyed()) return false; // not open → only the renderer can open it // so we hide right after rather than preventing it — and the window is
if (win.isVisible()) win.hide(); else win.show(); // skipTaskbar, so there is no animation to see.
refreshTray(); win.on('minimize', () => {
return true; win.hide();
} refreshTray();
});
export function showAllPaneWindows(): void {
windows.forEach((win) => { if (!win.isDestroyed() && !win.isVisible()) win.show(); }); // 'close' fires while the window still exists; 'closed' after it is gone. The
refreshTray(); // final geometry can only be read from the former.
} win.on('close', () => flushGeometry(win, paneId));
export function hideAllPaneWindows(): void { win.on('closed', () => {
windows.forEach((win) => { if (!win.isDestroyed() && win.isVisible()) win.hide(); }); windows.delete(paneId);
refreshTray(); 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
export function hasPaneWindow(paneId: string): boolean { // is inside and must be brought home.
const win = windows.get(paneId); });
return !!win && !win.isDestroyed();
} refreshTray();
}
// ── Wiring ──────────────────────────────────────────────────────────────────
export function closeAllPanes(): void {
export function initPaneHosts(deps: { getMainWindow: () => BrowserWindow | null }): void { // Called when the main window goes. A pane window holds a DOM node belonging to
getMainWindow = deps.getMainWindow; // 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
// The renderer pushes its registry whenever a pane is registered, opened or // window, so leaving one behind would stop `window-all-closed` from ever firing
// closed, so the tray can list panes it otherwise knows nothing about. // and the app would linger as an invisible process.
// Fire-and-forget: the tray is a view of the renderer's truth. Array.from(windows.values()).forEach((win) => { if (!win.isDestroyed()) win.destroy(); });
ipcMain.on(IPC_PANE_SYNC, (_event, panes: unknown) => { windows.clear();
lastSync = Array.isArray(panes) }
? panes.filter((p): p is TrayPane => !!p && typeof p.id === 'string' && typeof p.title === 'string')
: []; // ── Tray ────────────────────────────────────────────────────────────────────
refreshTray();
}); // 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[] = [];
export function getMainWindowRef(): BrowserWindow | null {
return getMainWindow(); 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();
}
export function hasPaneWindow(paneId: string): boolean {
const win = windows.get(paneId);
return !!win && !win.isDestroyed();
}
// ── 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.
ipcMain.on(IPC_PANE_SYNC, (_event, panes: unknown) => {
lastSync = Array.isArray(panes)
? panes.filter((p): p is TrayPane => !!p && typeof p.id === 'string' && typeof p.title === 'string')
: [];
refreshTray();
});
}
+135 -120
View File
@@ -1,120 +1,135 @@
// The system tray. // The system tray.
// //
// This is the first Tray in the app, and it exists for one reason: a pane you // This is the first Tray in the app, and it exists for one reason: a pane you
// popped out is furniture. You want it out of the way while you play and back // popped out is furniture. You want it out of the way while you play and back
// instantly when you don't — without hunting for it behind the main window or in // instantly when you don't — without hunting for it behind the main window or in
// a taskbar full of small companions. // a taskbar full of small companions.
// //
// The menu is a view of the RENDERER's pane registry (pushed up over pane:sync), // The menu is a view of the RENDERER's pane registry (pushed up over pane:sync),
// not a second copy of it. Main never decides what a pane is; it only shows and // not a second copy of it. Main never decides what a pane is; it only shows and
// hides windows. A pane the tray doesn't have a window for is toggled by asking // hides windows. A pane the tray doesn't have a window for is toggled by asking
// the renderer to open it — which is what pane:toggle is. // the renderer to open it — which is what pane:toggle is.
// //
// Icon: resolved from dist/main/, next to the compiled JS. build:ts copies it // Icon: resolved from dist/main/, next to the compiled JS. build:ts copies it
// there, which is the same trick splash.html/spinner.json use — it means // there, which is the same trick splash.html/spinner.json use — it means
// `__dirname` works identically in dev and in a packaged asar, with no // `__dirname` works identically in dev and in a packaged asar, with no
// app.isPackaged branch and nothing to add to electron-builder's extraResources. // app.isPackaged branch and nothing to add to electron-builder's extraResources.
import { Menu, Tray, nativeImage, app } from 'electron'; import { Menu, Tray, nativeImage, app } from 'electron';
import * as path from 'path'; import * as path from 'path';
import { IPC_PANE_EVENT_TOGGLE } from './ipc-channels'; import { IPC_PANE_EVENT_TOGGLE } from './ipc-channels';
import { togglePaneWindow, showAllPaneWindows, hideAllPaneWindows, hasPaneWindow } from './pane-hosts';
export interface TrayPane {
export interface TrayPane { id: string;
id: string; title: string;
title: string; icon?: string;
icon?: string; open?: boolean;
open?: boolean; }
}
// What the tray needs from the pane host, INJECTED rather than imported.
let tray: Tray | null = null; //
let panes: TrayPane[] = []; // pane-hosts imports setTrayPanes from this file, so importing back from there
let getMainWindow: () => Electron.BrowserWindow | null = () => null; // would make the two modules mutually dependent — and a require cycle in the main
// process is not a style question: whichever module loads second sees the other's
function iconPath(): string { // exports half-initialised, which fails at whatever moment the graph happens to
// Windows wants an .ico; macOS and Linux take a PNG. macOS additionally wants // resolve in. One direction only: pane-hosts → pane-tray, and the actions come in
// a monochrome template image, which the 16px PNG is not — so it will render // through initTray().
// in colour there. Acceptable, and preferable to shipping no tray at all; export interface TrayPaneActions {
// a proper …Template.png is a follow-up. getMainWindow: () => Electron.BrowserWindow | null;
return path.join(__dirname, process.platform === 'win32' ? 'tray.ico' : 'tray.png'); toggleWindow: (paneId: string) => boolean;
} showAll: () => void;
hideAll: () => void;
function showMainWindow(): void { hasWindow: (paneId: string) => boolean;
const win = getMainWindow(); }
if (!win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore(); let tray: Tray | null = null;
win.show(); let panes: TrayPane[] = [];
win.focus(); let actions: TrayPaneActions | null = null;
}
function iconPath(): string {
function buildMenu(): Menu { // Windows wants an .ico; macOS and Linux take a PNG. macOS additionally wants
const paneItems: Electron.MenuItemConstructorOptions[] = panes.map((p) => ({ // a monochrome template image, which the 16px PNG is not — so it will render
label: (p.icon ? p.icon + ' ' : '') + p.title, // in colour there. Acceptable, and preferable to shipping no tray at all;
type: 'checkbox', // a proper …Template.png is a follow-up.
checked: p.open === true, return path.join(__dirname, process.platform === 'win32' ? 'tray.ico' : 'tray.png');
click: () => { }
// If we already own a window for this pane, showing/hiding it is a
// main-process job and instant. If we don't, only the renderer can function showMainWindow(): void {
// decide what opening it means (it might belong in the dock), so ask. const win = actions?.getMainWindow() ?? null;
if (hasPaneWindow(p.id)) { togglePaneWindow(p.id); return; } if (!win || win.isDestroyed()) return;
const win = getMainWindow(); if (win.isMinimized()) win.restore();
if (win && !win.isDestroyed()) win.webContents.send(IPC_PANE_EVENT_TOGGLE, { paneId: p.id }); win.show();
}, win.focus();
})); }
const template: Electron.MenuItemConstructorOptions[] = [ function buildMenu(): Menu {
{ label: 'Show fee[dB]ack', click: showMainWindow }, const paneItems: Electron.MenuItemConstructorOptions[] = panes.map((p) => ({
{ type: 'separator' }, label: (p.icon ? p.icon + ' ' : '') + p.title,
]; type: 'checkbox',
checked: p.open === true,
if (paneItems.length) { click: () => {
template.push({ label: 'Panes', enabled: false }); // If we already own a window for this pane, showing/hiding it is a
template.push(...paneItems); // main-process job and instant. If we don't, only the renderer can
template.push({ type: 'separator' }); // decide what opening it means (it might belong in the dock), so ask.
template.push({ label: 'Show all panes', click: showAllPaneWindows }); if (actions?.hasWindow(p.id)) { actions.toggleWindow(p.id); return; }
template.push({ label: 'Hide all panes', click: hideAllPaneWindows }); const win = actions?.getMainWindow() ?? null;
} else { if (win && !win.isDestroyed()) win.webContents.send(IPC_PANE_EVENT_TOGGLE, { paneId: p.id });
template.push({ label: 'No panes', enabled: false }); },
} }));
template.push({ type: 'separator' }); const template: Electron.MenuItemConstructorOptions[] = [
template.push({ label: 'Quit fee[dB]ack', click: () => app.quit() }); { label: 'Show fee[dB]ack', click: showMainWindow },
{ type: 'separator' },
return Menu.buildFromTemplate(template); ];
}
if (paneItems.length) {
// Called by pane-hosts whenever the registry or the window state changes. The template.push({ label: 'Panes', enabled: false });
// whole menu is rebuilt — it is a handful of items, built only on user-visible template.push(...paneItems);
// state changes, and never on a playback path. template.push({ type: 'separator' });
export function setTrayPanes(next: TrayPane[]): void { template.push({ label: 'Show all panes', click: () => actions?.showAll() });
panes = next; template.push({ label: 'Hide all panes', click: () => actions?.hideAll() });
if (!tray || tray.isDestroyed()) return; } else {
tray.setContextMenu(buildMenu()); template.push({ label: 'No panes', enabled: false });
} }
export function initTray(deps: { getMainWindow: () => Electron.BrowserWindow | null }): void { template.push({ type: 'separator' });
if (tray) return; template.push({ label: 'Quit fee[dB]ack', click: () => app.quit() });
getMainWindow = deps.getMainWindow;
return Menu.buildFromTemplate(template);
const image = nativeImage.createFromPath(iconPath()); }
if (image.isEmpty()) {
// A Tray built from an empty image is an invisible tray: the menu exists // Called by pane-hosts whenever the registry or the window state changes. The
// but the user can never reach it. Fail loudly and simply go without — // whole menu is rebuilt — it is a handful of items, built only on user-visible
// panes still pop out, they just aren't tray-managed. // state changes, and never on a playback path.
console.warn(`[panes] tray icon missing or unreadable at ${iconPath()} — running without a tray`); export function setTrayPanes(next: TrayPane[]): void {
return; panes = next;
} if (!tray || tray.isDestroyed()) return;
tray.setContextMenu(buildMenu());
tray = new Tray(image); }
tray.setToolTip('fee[dB]ack');
tray.setContextMenu(buildMenu()); export function initTray(deps: TrayPaneActions): void {
// Left-click is the fast path back to the app, which is what a user reaching if (tray) return;
// for the tray almost always wants. (No-op on Linux, where most desktops give actions = deps;
// a left-click the context menu anyway.)
tray.on('click', showMainWindow); const image = nativeImage.createFromPath(iconPath());
} if (image.isEmpty()) {
// A Tray built from an empty image is an invisible tray: the menu exists
export function destroyTray(): void { // but the user can never reach it. Fail loudly and simply go without —
if (tray && !tray.isDestroyed()) tray.destroy(); // panes still pop out, they just aren't tray-managed.
tray = null; console.warn(`[panes] tray icon missing or unreadable at ${iconPath()} — running without a tray`);
} return;
}
tray = new Tray(image);
tray.setToolTip('fee[dB]ack');
tray.setContextMenu(buildMenu());
// Left-click is the fast path back to the app, which is what a user reaching
// for the tray almost always wants. (No-op on Linux, where most desktops give
// a left-click the context menu anyway.)
tray.on('click', showMainWindow);
}
export function destroyTray(): void {
if (tray && !tray.isDestroyed()) tray.destroy();
tray = null;
}