mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-10 21:34:09 +00:00
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:
+1431
-1419
File diff suppressed because it is too large
Load Diff
+255
-197
@@ -1,197 +1,255 @@
|
||||
// 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function savedFor(paneId: string): SavedPaneWindow {
|
||||
return getDesktopConfig().paneWindows?.[paneId] ?? {};
|
||||
}
|
||||
|
||||
function persist(paneId: string, patch: SavedPaneWindow): void {
|
||||
try {
|
||||
// setDesktopConfig merges shallowly, so paneWindows must be
|
||||
// read-modify-written or one pane's save would drop every other pane's.
|
||||
const all = { ...(getDesktopConfig().paneWindows ?? {}) };
|
||||
all[paneId] = { ...(all[paneId] ?? {}), ...patch };
|
||||
setDesktopConfig({ paneWindows: all });
|
||||
} catch (err) {
|
||||
console.warn(`[panes] failed to persist geometry for ${paneId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 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 => {
|
||||
if (win.isDestroyed()) return;
|
||||
persist(paneId, { bounds: { ...win.getNormalBounds(), maximized: false } });
|
||||
};
|
||||
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();
|
||||
});
|
||||
|
||||
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 {
|
||||
// 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(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
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.hide(); else win.show();
|
||||
refreshTray();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function showAllPaneWindows(): void {
|
||||
windows.forEach((win) => { if (!win.isDestroyed() && !win.isVisible()) win.show(); });
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
export function getMainWindowRef(): BrowserWindow | null {
|
||||
return getMainWindow();
|
||||
}
|
||||
// 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function savedFor(paneId: string): SavedPaneWindow {
|
||||
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.
|
||||
return Object.prototype.hasOwnProperty.call(saved, paneId) ? saved[paneId] : {};
|
||||
}
|
||||
|
||||
// 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 = getDesktopConfig().paneWindows ?? {};
|
||||
for (const key of Object.keys(saved)) {
|
||||
if (!isUnsafePaneId(key)) all[key] = saved[key];
|
||||
}
|
||||
all[paneId] = { ...(all[paneId] ?? {}), ...patch };
|
||||
setDesktopConfig({ paneWindows: { ...all } });
|
||||
} catch (err) {
|
||||
console.warn(`[panes] failed to persist geometry for ${paneId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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, { bounds: { ...win.getNormalBounds(), maximized: false } });
|
||||
}
|
||||
|
||||
// ── 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 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 : { bounds: { ...win.getNormalBounds(), maximized: false } });
|
||||
};
|
||||
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 {
|
||||
// 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();
|
||||
}
|
||||
|
||||
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
@@ -1,120 +1,135 @@
|
||||
// The system tray.
|
||||
//
|
||||
// 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
|
||||
// instantly when you don't — without hunting for it behind the main window or in
|
||||
// a taskbar full of small companions.
|
||||
//
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// `__dirname` works identically in dev and in a packaged asar, with no
|
||||
// app.isPackaged branch and nothing to add to electron-builder's extraResources.
|
||||
|
||||
import { Menu, Tray, nativeImage, app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { IPC_PANE_EVENT_TOGGLE } from './ipc-channels';
|
||||
import { togglePaneWindow, showAllPaneWindows, hideAllPaneWindows, hasPaneWindow } from './pane-hosts';
|
||||
|
||||
export interface TrayPane {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let panes: TrayPane[] = [];
|
||||
let getMainWindow: () => Electron.BrowserWindow | null = () => null;
|
||||
|
||||
function iconPath(): string {
|
||||
// Windows wants an .ico; macOS and Linux take a PNG. macOS additionally wants
|
||||
// a monochrome template image, which the 16px PNG is not — so it will render
|
||||
// in colour there. Acceptable, and preferable to shipping no tray at all;
|
||||
// a proper …Template.png is a follow-up.
|
||||
return path.join(__dirname, process.platform === 'win32' ? 'tray.ico' : 'tray.png');
|
||||
}
|
||||
|
||||
function showMainWindow(): void {
|
||||
const win = getMainWindow();
|
||||
if (!win || win.isDestroyed()) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function buildMenu(): Menu {
|
||||
const paneItems: Electron.MenuItemConstructorOptions[] = panes.map((p) => ({
|
||||
label: (p.icon ? p.icon + ' ' : '') + p.title,
|
||||
type: 'checkbox',
|
||||
checked: p.open === true,
|
||||
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
|
||||
// decide what opening it means (it might belong in the dock), so ask.
|
||||
if (hasPaneWindow(p.id)) { togglePaneWindow(p.id); return; }
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) win.webContents.send(IPC_PANE_EVENT_TOGGLE, { paneId: p.id });
|
||||
},
|
||||
}));
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{ label: 'Show fee[dB]ack', click: showMainWindow },
|
||||
{ type: 'separator' },
|
||||
];
|
||||
|
||||
if (paneItems.length) {
|
||||
template.push({ label: 'Panes', enabled: false });
|
||||
template.push(...paneItems);
|
||||
template.push({ type: 'separator' });
|
||||
template.push({ label: 'Show all panes', click: showAllPaneWindows });
|
||||
template.push({ label: 'Hide all panes', click: hideAllPaneWindows });
|
||||
} else {
|
||||
template.push({ label: 'No panes', enabled: false });
|
||||
}
|
||||
|
||||
template.push({ type: 'separator' });
|
||||
template.push({ label: 'Quit fee[dB]ack', click: () => app.quit() });
|
||||
|
||||
return Menu.buildFromTemplate(template);
|
||||
}
|
||||
|
||||
// Called by pane-hosts whenever the registry or the window state changes. The
|
||||
// whole menu is rebuilt — it is a handful of items, built only on user-visible
|
||||
// state changes, and never on a playback path.
|
||||
export function setTrayPanes(next: TrayPane[]): void {
|
||||
panes = next;
|
||||
if (!tray || tray.isDestroyed()) return;
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
export function initTray(deps: { getMainWindow: () => Electron.BrowserWindow | null }): void {
|
||||
if (tray) return;
|
||||
getMainWindow = deps.getMainWindow;
|
||||
|
||||
const image = nativeImage.createFromPath(iconPath());
|
||||
if (image.isEmpty()) {
|
||||
// A Tray built from an empty image is an invisible tray: the menu exists
|
||||
// but the user can never reach it. Fail loudly and simply go without —
|
||||
// panes still pop out, they just aren't tray-managed.
|
||||
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;
|
||||
}
|
||||
// The system tray.
|
||||
//
|
||||
// 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
|
||||
// instantly when you don't — without hunting for it behind the main window or in
|
||||
// a taskbar full of small companions.
|
||||
//
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// `__dirname` works identically in dev and in a packaged asar, with no
|
||||
// app.isPackaged branch and nothing to add to electron-builder's extraResources.
|
||||
|
||||
import { Menu, Tray, nativeImage, app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { IPC_PANE_EVENT_TOGGLE } from './ipc-channels';
|
||||
|
||||
export interface TrayPane {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
// What the tray needs from the pane host, INJECTED rather than imported.
|
||||
//
|
||||
// pane-hosts imports setTrayPanes from this file, so importing back from there
|
||||
// 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
|
||||
// exports half-initialised, which fails at whatever moment the graph happens to
|
||||
// resolve in. One direction only: pane-hosts → pane-tray, and the actions come in
|
||||
// through initTray().
|
||||
export interface TrayPaneActions {
|
||||
getMainWindow: () => Electron.BrowserWindow | null;
|
||||
toggleWindow: (paneId: string) => boolean;
|
||||
showAll: () => void;
|
||||
hideAll: () => void;
|
||||
hasWindow: (paneId: string) => boolean;
|
||||
}
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let panes: TrayPane[] = [];
|
||||
let actions: TrayPaneActions | null = null;
|
||||
|
||||
function iconPath(): string {
|
||||
// Windows wants an .ico; macOS and Linux take a PNG. macOS additionally wants
|
||||
// a monochrome template image, which the 16px PNG is not — so it will render
|
||||
// in colour there. Acceptable, and preferable to shipping no tray at all;
|
||||
// a proper …Template.png is a follow-up.
|
||||
return path.join(__dirname, process.platform === 'win32' ? 'tray.ico' : 'tray.png');
|
||||
}
|
||||
|
||||
function showMainWindow(): void {
|
||||
const win = actions?.getMainWindow() ?? null;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function buildMenu(): Menu {
|
||||
const paneItems: Electron.MenuItemConstructorOptions[] = panes.map((p) => ({
|
||||
label: (p.icon ? p.icon + ' ' : '') + p.title,
|
||||
type: 'checkbox',
|
||||
checked: p.open === true,
|
||||
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
|
||||
// decide what opening it means (it might belong in the dock), so ask.
|
||||
if (actions?.hasWindow(p.id)) { actions.toggleWindow(p.id); return; }
|
||||
const win = actions?.getMainWindow() ?? null;
|
||||
if (win && !win.isDestroyed()) win.webContents.send(IPC_PANE_EVENT_TOGGLE, { paneId: p.id });
|
||||
},
|
||||
}));
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{ label: 'Show fee[dB]ack', click: showMainWindow },
|
||||
{ type: 'separator' },
|
||||
];
|
||||
|
||||
if (paneItems.length) {
|
||||
template.push({ label: 'Panes', enabled: false });
|
||||
template.push(...paneItems);
|
||||
template.push({ type: 'separator' });
|
||||
template.push({ label: 'Show all panes', click: () => actions?.showAll() });
|
||||
template.push({ label: 'Hide all panes', click: () => actions?.hideAll() });
|
||||
} else {
|
||||
template.push({ label: 'No panes', enabled: false });
|
||||
}
|
||||
|
||||
template.push({ type: 'separator' });
|
||||
template.push({ label: 'Quit fee[dB]ack', click: () => app.quit() });
|
||||
|
||||
return Menu.buildFromTemplate(template);
|
||||
}
|
||||
|
||||
// Called by pane-hosts whenever the registry or the window state changes. The
|
||||
// whole menu is rebuilt — it is a handful of items, built only on user-visible
|
||||
// state changes, and never on a playback path.
|
||||
export function setTrayPanes(next: TrayPane[]): void {
|
||||
panes = next;
|
||||
if (!tray || tray.isDestroyed()) return;
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
export function initTray(deps: TrayPaneActions): void {
|
||||
if (tray) return;
|
||||
actions = deps;
|
||||
|
||||
const image = nativeImage.createFromPath(iconPath());
|
||||
if (image.isEmpty()) {
|
||||
// A Tray built from an empty image is an invisible tray: the menu exists
|
||||
// but the user can never reach it. Fail loudly and simply go without —
|
||||
// panes still pop out, they just aren't tray-managed.
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user