feat(panes)!: dress the renderer's pane window, don't create it

Follows the core change: a pane is now the plugin's REAL panel element,
moved into the pop-out window and still running the plugin's own code
(got-feedback/feedback#928).

That forces one thing here, and it is worth being loud about it:

  WE MUST NOT CREATE THE PANE WINDOW.

To move a live DOM node into another window, the renderer needs a handle on
that window's document. A BrowserWindow we construct in the main process
gives it no such handle. So the renderer opens the window itself with
window.open(), Electron's setWindowOpenHandler turns that into a real
BrowserWindow anyway, and we recognise it in did-create-window by the frame
name the renderer gave it (`fbpane-<paneId>`) and attach the OS behaviour:
remembered bounds, off the taskbar, minimize-to-tray, listed in the tray.

Create the window here instead and the whole feature collapses back into
"reimplement the panel in the pop-out and sync it over IPC" — which is
exactly what we just deleted.

The IPC surface shrinks to two channels, because main never creates or
destroys a pane window and never looks inside one:

  pane:sync    renderer → main   the registry, so the tray can list panes
  pane:toggle  main → renderer   the tray asking for a pane; only the
                                 renderer knows what opening one means (it
                                 might belong in the dock, and its element
                                 lives there)

Gone: pane:open, pane:close, pane:focus, pane:setAlwaysOnTop, pane:closed.
The renderer holds the WindowProxy for a window it opened, so it already
knows when the user closes it — and it has to, because its element is inside
and must be brought home.

Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
topkoa
2026-07-12 18:14:49 -04:00
parent 39251f3d12
commit 938ddded60
4 changed files with 130 additions and 228 deletions
+14 -19
View File
@@ -33,26 +33,21 @@ export const IPC_MAINTENANCE_RESTART = 'maintenance:restart' as const;
// powerSaveBlocker here instead. See got-feedback/feedback#686. // powerSaveBlocker here instead. See got-feedback/feedback#686.
export const IPC_POWER_SET_SCREEN_AWAKE = 'power:setScreenAwake' as const; export const IPC_POWER_SET_SCREEN_AWAKE = 'power:setScreenAwake' as const;
// Detachable panes (feedBack core's window.feedBack.panes). The renderer owns // Detachable panes (feedBack core's window.feedBack.panes).
// the truth — which panes exist, which are open, and what goes in them; the main
// process owns the OS surfaces: one BrowserWindow per popped-out pane, their
// remembered geometry, and the system tray.
// //
// The pane window loads <renderer origin>/pane, so it shares the renderer's // Deliberately tiny. The renderer OPENS its own pane windows with window.open() —
// BroadcastChannel scope and talks to the app over the same channel a browser // it has to, because it moves a live DOM node into them and needs a handle on the
// pop-out would. Main never sees a pane's contents. // new document to do it (see pane-hosts.ts). Electron turns that same-origin
export const IPC_PANE_OPEN = 'pane:open' as const; // window.open() into a real BrowserWindow, and main recognises it by its frame
export const IPC_PANE_CLOSE = 'pane:close' as const; // name. So there is no open/close/focus channel: main never creates or destroys a
export const IPC_PANE_FOCUS = 'pane:focus' as const; // pane window, it only dresses one up.
export const IPC_PANE_SET_ALWAYS_ON_TOP = 'pane:setAlwaysOnTop' as const; //
// The renderer pushes its pane registry up whenever it changes, so the tray menu // That leaves exactly two things to say across the boundary.
// can list panes it otherwise knows nothing about.
// Renderer → main: the pane registry, so the tray can list panes it otherwise
// knows nothing about.
export const IPC_PANE_SYNC = 'pane:sync' as const; export const IPC_PANE_SYNC = 'pane:sync' as const;
// One-way pushes, main → renderer. // Main → renderer: the tray asked to open or close a pane. Only the renderer knows
// A pane window the user closed (or that crashed): the renderer must close the // what that means — the pane may belong in the dock, and its element lives there.
// pane so the chip's hidden dialog comes back.
export const IPC_PANE_EVENT_CLOSED = 'pane:closed' as const;
// The tray asked for a pane to be opened or closed. The renderer decides what
// that means and calls back through pane:open / pane:close.
export const IPC_PANE_EVENT_TOGGLE = 'pane:toggle' as const; export const IPC_PANE_EVENT_TOGGLE = 'pane:toggle' as const;
+14 -13
View File
@@ -126,7 +126,7 @@ import * as updateManager from './update-manager';
import type { UpdateChannel } from './update-manager'; import type { UpdateChannel } from './update-manager';
import { installAppMenu } from './app-menu'; import { installAppMenu } from './app-menu';
import { sanitizeWindowBounds, MIN_WIDTH, MIN_HEIGHT } from './window-bounds'; import { sanitizeWindowBounds, MIN_WIDTH, MIN_HEIGHT } from './window-bounds';
import { initPaneHosts, closeAllPanes } from './pane-hosts'; import { initPaneHosts, closeAllPanes, adoptPaneWindow, paneIdFromFrameName } from './pane-hosts';
import { initTray, destroyTray } from './pane-tray'; import { initTray, destroyTray } from './pane-tray';
// Linux: enable Chromium's PipeWire capturer feature so getUserMedia can see // Linux: enable Chromium's PipeWire capturer feature so getUserMedia can see
@@ -781,8 +781,15 @@ function createWindow(port: number): void {
wc.setWindowOpenHandler(rendererWindowOpenHandler); wc.setWindowOpenHandler(rendererWindowOpenHandler);
wc.on('did-create-window', (nestedWin) => wirePopupGuards(nestedWin.webContents)); wc.on('did-create-window', (nestedWin) => wirePopupGuards(nestedWin.webContents));
} }
mainWindow.webContents.on('did-create-window', (popupWin) => { mainWindow.webContents.on('did-create-window', (popupWin, details) => {
wirePopupGuards(popupWin.webContents); wirePopupGuards(popupWin.webContents);
// A pane pop-out. The RENDERER opened it (window.open) because it moves a
// live DOM node into it and needs a handle on the new document to do that —
// see pane-hosts.ts. We recognise it by the frame name it was opened with
// and give it the OS behaviour a pane should have: remembered bounds, off
// the taskbar, minimize-to-tray, listed in the tray menu.
const paneId = paneIdFromFrameName(details.frameName || '');
if (paneId) adoptPaneWindow(popupWin, paneId);
}); });
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
@@ -1176,17 +1183,11 @@ async function startup(): Promise<void> {
// Create the main window // Create the main window
createWindow(port); createWindow(port);
// Detachable panes: real BrowserWindows for popped-out panes, plus the tray // Detachable panes: the tray that lists them, and the OS behaviour applied to
// that lists them. Must come after createWindow — the pane host and the tray // each pane window as the renderer opens it (see did-create-window above).
// both reach the renderer through mainWindow, and Tray requires a ready app. // Must come after createWindow — both reach the renderer through mainWindow,
// The origin predicate is the same one the navigation guards use, so a pane // and Tray requires a ready app.
// window can only ever load OUR renderer, never arbitrary web content with initPaneHosts({ getMainWindow: () => mainWindow });
// the preload bridge attached.
initPaneHosts({
getMainWindow: () => mainWindow,
isRendererOrigin: makeRendererOriginPredicate(port),
webPreferences: rendererWebPreferences,
});
initTray({ getMainWindow: () => mainWindow }); initTray({ getMainWindow: () => mainWindow });
// Install our application menu (replaces Electron's default so View → // Install our application menu (replaces Electron's default so View →
+96 -167
View File
@@ -1,59 +1,48 @@
// Pane pop-out windows. // Pane pop-out windows.
// //
// feedBack core has a pane system (window.feedBack.panes): live UI — a mixer, a // feedBack core has a pane system (window.feedBack.panes): a plugin's panel — a
// camera rig, a readout — authored once and hostable anywhere. In a plain // mixer, a camera rig, a readout — popped out of the app into its own window and
// browser it pops out via window.open(). Here it gets a real BrowserWindow, with // left there. Here it gets the desktop treatment: remembered geometry, off the
// remembered geometry, always-on-top, and a system tray (pane-tray.ts). // taskbar, minimize-to-tray, and a tray menu that lists every pane (pane-tray.ts).
// //
// Division of labour: THE RENDERER OWNS THE TRUTH. It knows which panes exist, // READ THIS BEFORE "TIDYING UP" THE WINDOW CREATION.
// which are open, and what is in them. This module owns OS surfaces only —
// windows and their geometry. It never looks inside a pane.
// //
// The window loads `<renderer origin>/pane?...`, which matters for one specific // We do NOT create these windows. The renderer opens them with window.open(), and
// reason: a pane is fed over BroadcastChannel, and BroadcastChannel only reaches // Electron's setWindowOpenHandler (main.ts) turns that into a real BrowserWindow
// windows in the same Chromium instance and origin. Push the URL to the system // for us. That is not an accident and it is not laziness:
// browser and the pane opens looking perfect and never updates again. That is //
// also why main.ts's setWindowOpenHandler answers same-origin URLs with // The pane's element is MOVED into the pop-out window — the actual DOM node,
// `action: 'allow'` rather than `deny` + shell.openExternal. // 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 { BrowserWindow, ipcMain, screen } from 'electron';
import { import { IPC_PANE_SYNC } from './ipc-channels';
IPC_PANE_OPEN,
IPC_PANE_CLOSE,
IPC_PANE_FOCUS,
IPC_PANE_SET_ALWAYS_ON_TOP,
IPC_PANE_SYNC,
IPC_PANE_EVENT_CLOSED,
} 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';
// A pane window is small by nature. The main window's 800x600 floor would // The renderer names the frame `fbpane-<paneId>`. Keep in sync with
// inflate one threefold, which is why sanitizeWindowBounds takes sizing now. // 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 = { const PANE_SIZING: WindowSizing = {
minWidth: 260, minWidth: 240,
minHeight: 200, minHeight: 180,
defaultWidth: 380, defaultWidth: 380,
defaultHeight: 560, defaultHeight: 560,
}; };
interface PaneOpenRequest {
paneId: string;
url: string;
title: string;
width?: number;
height?: number;
}
const windows = new Map<string, BrowserWindow>(); const windows = new Map<string, BrowserWindow>();
let getMainWindow: () => BrowserWindow | null = () => null; let getMainWindow: () => BrowserWindow | null = () => null;
let isRendererOrigin: (url: string) => boolean = () => false;
function notifyRenderer(channel: string, payload: unknown): void {
const win = getMainWindow();
if (win && !win.isDestroyed()) win.webContents.send(channel, payload);
}
// ── Geometry ──────────────────────────────────────────────────────────────── // ── Geometry ────────────────────────────────────────────────────────────────
@@ -73,111 +62,83 @@ function persist(paneId: string, patch: SavedPaneWindow): void {
} }
} }
// ── Windows ───────────────────────────────────────────────────────────────── // ── Adoption ────────────────────────────────────────────────────────────────
function openPane(req: PaneOpenRequest, webPreferences: Electron.WebPreferences): boolean { export function paneIdFromFrameName(frameName: string): string | null {
// IPC is untyped at runtime. Validate before handing anything to if (!frameName || !frameName.startsWith(FRAME_PREFIX)) return null;
// BrowserWindow — and above all, refuse a URL that is not the renderer's own const id = frameName.slice(FRAME_PREFIX.length);
// origin, or we would be opening arbitrary web content with the full preload return id ? id : null;
// bridge attached. }
if (typeof req?.paneId !== 'string' || !req.paneId) return false;
if (typeof req?.url !== 'string' || !isRendererOrigin(req.url)) {
console.warn(`[panes] refusing to open a pane at a non-renderer origin: ${String(req?.url)}`);
return false;
}
const existing = windows.get(req.paneId); // Called from main.ts's did-create-window when the renderer pops a pane out.
if (existing && !existing.isDestroyed()) { export function adoptPaneWindow(win: BrowserWindow, paneId: string): void {
// Already open: show it rather than opening a second copy. A pane that is windows.set(paneId, win);
// hidden in the tray comes back here.
if (!existing.isVisible()) existing.show();
existing.focus();
return true;
}
const saved = savedFor(req.paneId); const saved = savedFor(paneId);
const restored = sanitizeWindowBounds( const restored = sanitizeWindowBounds(
saved.bounds, saved.bounds,
screen.getAllDisplays().map((d) => d.workArea), screen.getAllDisplays().map((d) => d.workArea),
{ // The size window.open() asked for is the fallback for a pane that has
...PANE_SIZING, // never been opened before; the saved bounds win once it has.
// The pane's own declared size is the fallback when it has never been { ...PANE_SIZING, defaultWidth: win.getBounds().width, defaultHeight: win.getBounds().height },
// opened before; the saved bounds win once it has.
defaultWidth: req.width ?? PANE_SIZING.defaultWidth,
defaultHeight: req.height ?? PANE_SIZING.defaultHeight,
},
); );
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);
const win = new BrowserWindow({ // A pane is a companion to the app, not an entry to it: keep it off the taskbar
x: restored.x, // so it never masquerades as a second fee[dB]ack.
y: restored.y, win.setSkipTaskbar(true);
width: restored.width,
height: restored.height,
minWidth: PANE_SIZING.minWidth,
minHeight: PANE_SIZING.minHeight,
title: req.title || 'fee[dB]ack',
backgroundColor: '#0f172a',
alwaysOnTop: saved.alwaysOnTop === true,
// 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.
skipTaskbar: true,
webPreferences,
});
windows.set(req.paneId, win); // 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
// Persist on move/resize, not just on close — a pane window can outlive the // place it twice.
// app in a crash, and the whole point of remembering geometry is that the
// user never has to place it twice.
const save = (): void => { const save = (): void => {
if (win.isDestroyed()) return; if (win.isDestroyed()) return;
persist(req.paneId, { bounds: { ...win.getNormalBounds(), maximized: false } }); persist(paneId, { bounds: { ...win.getNormalBounds(), maximized: false } });
}; };
win.on('moved', save); win.on('moved', save);
win.on('resized', save); win.on('resized', save);
// Minimize sends the pane to the tray, not to the taskbar. Panes are small // Minimize sends a pane to the tray, not the taskbar. Panes are small and
// and numerous; a taskbar full of them is noise, and the tray already lists // numerous; a taskbar full of them is noise, and the tray already lists them.
// them. Electron's 'minimize' is not cancellable (the listener takes no // Electron's 'minimize' is not cancellable here (the listener takes no event),
// event), so we hide immediately after rather than preventing it — and since // so we hide right after rather than preventing it — and the window is
// the window is skipTaskbar there is no minimize animation to see. // skipTaskbar, so there is no animation to see.
win.on('minimize', () => { win.on('minimize', () => {
win.hide(); win.hide();
refreshTray(); refreshTray();
}); });
win.on('closed', () => { win.on('closed', () => {
windows.delete(req.paneId); windows.delete(paneId);
// Tell the renderer, or the pane stays "open" in its registry forever —
// and the dialog the pop-out chip hid never comes back, leaving the user
// with no way to reach their own UI.
notifyRenderer(IPC_PANE_EVENT_CLOSED, { paneId: req.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
// is inside and must be brought home.
}); });
void win.loadURL(req.url);
refreshTray(); refreshTray();
return true;
}
function closePane(paneId: string): void {
const win = windows.get(paneId);
windows.delete(paneId);
if (win && !win.isDestroyed()) win.destroy();
} }
export function closeAllPanes(): void { export function closeAllPanes(): void {
// Called when the main window goes. A pane window can never be fed again // Called when the main window goes. A pane window holds a DOM node belonging to
// without it — and worse, a HIDDEN pane window is still an open window, so // the main window's document — with the main window gone there is nothing left
// leaving one behind would keep `window-all-closed` from ever firing and the // to dock it back into. And worse: a pane HIDDEN in the tray is still an open
// app would linger as an invisible process. // window, so leaving one behind would stop `window-all-closed` from ever firing
Array.from(windows.keys()).forEach(closePane); // and the app would linger as an invisible process.
Array.from(windows.values()).forEach((win) => { if (!win.isDestroyed()) win.destroy(); });
windows.clear();
} }
// ── Tray ──────────────────────────────────────────────────────────────────── // ── Tray ────────────────────────────────────────────────────────────────────
// The renderer's last known pane registry. The tray menu is built from this plus // The renderer's last known pane registry. The tray menu is a VIEW of it, never a
// the live window state, so the tray can offer panes it knows nothing else about. // second copy — main has no idea what a pane contains, and does not need one.
let lastSync: TrayPane[] = []; let lastSync: TrayPane[] = [];
function refreshTray(): void { function refreshTray(): void {
@@ -185,68 +146,16 @@ function refreshTray(): void {
const win = windows.get(p.id); const win = windows.get(p.id);
return { return {
...p, ...p,
// "open" from the tray's point of view means "has a visible window". // "open", to the tray, means "has a visible window". A pane docked inside
// A pane docked inside the main window is not something the tray can // the main window is not something the tray can usefully show or hide.
// usefully show or hide.
open: !!win && !win.isDestroyed() && win.isVisible(), open: !!win && !win.isDestroyed() && win.isVisible(),
}; };
})); }));
} }
// ── Wiring ──────────────────────────────────────────────────────────────────
export function initPaneHosts(deps: {
getMainWindow: () => BrowserWindow | null;
isRendererOrigin: (url: string) => boolean;
webPreferences: Electron.WebPreferences;
}): void {
getMainWindow = deps.getMainWindow;
isRendererOrigin = deps.isRendererOrigin;
ipcMain.handle(IPC_PANE_OPEN, (_event, req: unknown) => openPane(req as PaneOpenRequest, deps.webPreferences));
ipcMain.handle(IPC_PANE_CLOSE, (_event, paneId: unknown) => {
if (typeof paneId !== 'string') return false;
closePane(paneId);
refreshTray();
return true;
});
ipcMain.handle(IPC_PANE_FOCUS, (_event, paneId: unknown) => {
if (typeof paneId !== 'string') return false;
const win = windows.get(paneId);
if (!win || win.isDestroyed()) return false;
if (!win.isVisible()) win.show();
win.focus();
refreshTray();
return true;
});
ipcMain.handle(IPC_PANE_SET_ALWAYS_ON_TOP, (_event, paneId: unknown, value: unknown) => {
if (typeof paneId !== 'string') return false;
const on = value === true;
const win = windows.get(paneId);
if (win && !win.isDestroyed()) win.setAlwaysOnTop(on);
persist(paneId, { alwaysOnTop: on });
return true;
});
// The renderer pushes its registry whenever a pane is registered, opened or
// closed. Fire-and-forget: the tray is a view of the renderer's truth, never
// a second copy of it.
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();
});
}
// Exposed for the tray: show/hide a pane window we already have.
export function togglePaneWindow(paneId: string): boolean { export function togglePaneWindow(paneId: string): boolean {
const win = windows.get(paneId); const win = windows.get(paneId);
if (!win || win.isDestroyed()) return false; // not open → the renderer must open it if (!win || win.isDestroyed()) return false; // not open → only the renderer can open it
if (win.isVisible()) win.hide(); else win.show(); if (win.isVisible()) win.hide(); else win.show();
refreshTray(); refreshTray();
return true; return true;
@@ -266,3 +175,23 @@ export function hasPaneWindow(paneId: string): boolean {
const win = windows.get(paneId); const win = windows.get(paneId);
return !!win && !win.isDestroyed(); 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();
});
}
export function getMainWindowRef(): BrowserWindow | null {
return getMainWindow();
}
+6 -29
View File
@@ -33,12 +33,7 @@ import {
IPC_MAINTENANCE_GET_PATHS, IPC_MAINTENANCE_GET_PATHS,
IPC_MAINTENANCE_RESET, IPC_MAINTENANCE_RESET,
IPC_MAINTENANCE_RESTART, IPC_MAINTENANCE_RESTART,
IPC_PANE_OPEN,
IPC_PANE_CLOSE,
IPC_PANE_FOCUS,
IPC_PANE_SET_ALWAYS_ON_TOP,
IPC_PANE_SYNC, IPC_PANE_SYNC,
IPC_PANE_EVENT_CLOSED,
IPC_PANE_EVENT_TOGGLE, IPC_PANE_EVENT_TOGGLE,
} from './ipc-channels'; } from './ipc-channels';
@@ -585,33 +580,15 @@ const feedBackDesktopApi = {
power: { power: {
setScreenAwake: (keep: boolean) => ipcRenderer.invoke(IPC_POWER_SET_SCREEN_AWAKE, keep), setScreenAwake: (keep: boolean) => ipcRenderer.invoke(IPC_POWER_SET_SCREEN_AWAKE, keep),
}, },
// Detachable panes. feedBack core's pane system (window.feedBack.panes) pops // Detachable panes. The renderer opens its own pane windows with window.open()
// a pane out with window.open() in a browser; here it asks for a real // — it moves a live DOM node into them and needs a handle on the new document
// BrowserWindow instead — one that remembers where you put it, can float // to do it — and Electron turns that into a real BrowserWindow, which main
// above everything, and lives in the system tray. // recognises by its frame name and gives remembered bounds, skip-taskbar and a
// // tray entry. So there is nothing here about opening or closing windows: only
// The renderer stays the authority: it says which panes exist and what goes // the registry the tray needs, and the tray asking for a pane.
// in them, and pushes that registry up with sync() so the tray can list them.
// Main only owns the windows. Note the pane window loads the renderer's OWN
// origin (/pane), so it shares the BroadcastChannel the app feeds panes over.
panes: { panes: {
open: (req: { paneId: string; url: string; title: string; width?: number; height?: number }) =>
ipcRenderer.invoke(IPC_PANE_OPEN, req),
close: (paneId: string) => ipcRenderer.invoke(IPC_PANE_CLOSE, paneId),
focus: (paneId: string) => ipcRenderer.invoke(IPC_PANE_FOCUS, paneId),
setAlwaysOnTop: (paneId: string, value: boolean) =>
ipcRenderer.invoke(IPC_PANE_SET_ALWAYS_ON_TOP, paneId, value),
sync: (panes: Array<{ id: string; title: string; icon?: string; open?: boolean }>) => sync: (panes: Array<{ id: string; title: string; icon?: string; open?: boolean }>) =>
ipcRenderer.send(IPC_PANE_SYNC, panes), ipcRenderer.send(IPC_PANE_SYNC, panes),
// The user closed a pane window (or it crashed). The renderer must close
// the pane, or the dialog its pop-out chip hid never comes back.
onClosed: (callback: (paneId: string) => void) => {
const listener = (_event: unknown, payload: { paneId: string }) => callback(payload.paneId);
ipcRenderer.on(IPC_PANE_EVENT_CLOSED, listener);
return () => ipcRenderer.removeListener(IPC_PANE_EVENT_CLOSED, listener);
},
// The tray asked to open/close a pane it has no window for. Only the
// renderer knows what that means.
onToggle: (callback: (paneId: string) => void) => { onToggle: (callback: (paneId: string) => void) => {
const listener = (_event: unknown, payload: { paneId: string }) => callback(payload.paneId); const listener = (_event: unknown, payload: { paneId: string }) => callback(payload.paneId);
ipcRenderer.on(IPC_PANE_EVENT_TOGGLE, listener); ipcRenderer.on(IPC_PANE_EVENT_TOGGLE, listener);