mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-18 22:42:25 +00:00
Merge pull request #103 from got-feedBack/feat/pane-host-tray
feat(panes): pane pop-out windows + the system tray
This commit is contained in:
+1
-1
@@ -11,7 +11,7 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test \"tests/*.test.js\"",
|
||||
"build:ts": "tsc && node -e \"const fs=require('fs'),path=require('path');fs.copyFileSync('src/main/splash.html','dist/main/splash.html');fs.copyFileSync('src/main/spinner.json','dist/main/spinner.json');fs.copyFileSync('node_modules/lottie-web/build/player/lottie.min.js','dist/main/lottie.min.js');const dst='dist/main/images';if(fs.existsSync(dst))fs.rmSync(dst,{recursive:true});fs.mkdirSync(dst,{recursive:true});fs.readdirSync('src/main/images',{withFileTypes:true}).filter(d=>d.isFile()&&d.name.endsWith('.webp')).forEach(d=>fs.copyFileSync(path.join('src/main/images',d.name),path.join(dst,d.name)))\"",
|
||||
"build:ts": "tsc && node -e \"const fs=require('fs'),path=require('path');fs.copyFileSync('src/main/splash.html','dist/main/splash.html');fs.copyFileSync('src/main/spinner.json','dist/main/spinner.json');fs.copyFileSync('node_modules/lottie-web/build/player/lottie.min.js','dist/main/lottie.min.js');const dst='dist/main/images';if(fs.existsSync(dst))fs.rmSync(dst,{recursive:true});fs.mkdirSync(dst,{recursive:true});fs.readdirSync('src/main/images',{withFileTypes:true}).filter(d=>d.isFile()&&d.name.endsWith('.webp')).forEach(d=>fs.copyFileSync(path.join('src/main/images',d.name),path.join(dst,d.name)));fs.copyFileSync('resources/icons/icon.ico','dist/main/tray.ico');fs.copyFileSync('resources/icons/32x32.png','dist/main/tray.png')\"",
|
||||
"build:audio": "bash scripts/build-audio.sh Release",
|
||||
"build:audio:debug": "bash scripts/build-audio.sh Debug",
|
||||
"rebuild:audio": "rm -rf build && bash scripts/build-audio.sh Release",
|
||||
|
||||
@@ -32,3 +32,22 @@ export const IPC_MAINTENANCE_RESTART = 'maintenance:restart' as const;
|
||||
// honour the renderer's navigator.wakeLock reliably, so we drive Electron's
|
||||
// powerSaveBlocker here instead. See got-feedback/feedback#686.
|
||||
export const IPC_POWER_SET_SCREEN_AWAKE = 'power:setScreenAwake' as const;
|
||||
|
||||
// Detachable panes (feedBack core's window.feedBack.panes).
|
||||
//
|
||||
// Deliberately tiny. The renderer OPENS its own pane windows with window.open() —
|
||||
// it has to, because it moves a live DOM node into them and needs a handle on the
|
||||
// new document to do it (see pane-hosts.ts). Electron turns that same-origin
|
||||
// window.open() into a real BrowserWindow, and main recognises it by its frame
|
||||
// name. So there is no open/close/focus channel: main never creates or destroys a
|
||||
// pane window, it only dresses one up.
|
||||
//
|
||||
// That leaves exactly two things to say across the boundary.
|
||||
|
||||
// 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;
|
||||
|
||||
// Main → renderer: the tray asked to open or close a pane. Only the renderer knows
|
||||
// what that means — the pane may belong in the dock, and its element lives there.
|
||||
export const IPC_PANE_EVENT_TOGGLE = 'pane:toggle' as const;
|
||||
|
||||
+1430
-1395
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
// Pane pop-out windows.
|
||||
//
|
||||
// feedBack core has a pane system (window.feedBack.panes): a plugin's panel — a
|
||||
// mixer, a camera rig, a readout — popped out of the app into its own window and
|
||||
// left there. Here it gets the desktop treatment: remembered geometry, off the
|
||||
// taskbar, minimize-to-tray, and a tray menu that lists every pane (pane-tray.ts).
|
||||
//
|
||||
// READ THIS BEFORE "TIDYING UP" THE WINDOW CREATION.
|
||||
//
|
||||
// We do NOT create these windows. The renderer opens them with window.open(), and
|
||||
// Electron's setWindowOpenHandler (main.ts) turns that into a real BrowserWindow
|
||||
// for us. That is not an accident and it is not laziness:
|
||||
//
|
||||
// The pane's element is MOVED into the pop-out window — the actual DOM node,
|
||||
// adopted across same-origin documents, so it keeps its listeners and its
|
||||
// closures and goes on running the plugin's own code. To adopt it, the renderer
|
||||
// needs a handle on the new window's document. A window WE created in the main
|
||||
// process gives it no such handle. Create the window here and the whole feature
|
||||
// collapses back into "reimplement the panel and sync it over IPC".
|
||||
//
|
||||
// So the renderer opens the window, names its frame `fbpane-<paneId>`, and we
|
||||
// recognise it in main.ts's did-create-window and attach the OS behaviour. The
|
||||
// renderer keeps the DOM link; we supply the window manners.
|
||||
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||
import { IPC_PANE_SYNC } from './ipc-channels';
|
||||
import { sanitizeWindowBounds, type WindowSizing } from './window-bounds';
|
||||
import { getDesktopConfig, setDesktopConfig, type SavedPaneWindow } from './soundfont-manager';
|
||||
import { setTrayPanes, type TrayPane } from './pane-tray';
|
||||
|
||||
// The renderer names the frame `fbpane-<paneId>`. Keep in sync with
|
||||
// static/panes/pane-window-host.js.
|
||||
const FRAME_PREFIX = 'fbpane-';
|
||||
|
||||
// A pane window is small by nature. The main window's 800x600 floor would inflate
|
||||
// one threefold, which is why sanitizeWindowBounds takes sizing now.
|
||||
const PANE_SIZING: WindowSizing = {
|
||||
minWidth: 240,
|
||||
minHeight: 180,
|
||||
defaultWidth: 380,
|
||||
defaultHeight: 560,
|
||||
};
|
||||
|
||||
const windows = new Map<string, BrowserWindow>();
|
||||
let getMainWindow: () => BrowserWindow | null = () => null;
|
||||
|
||||
// ── Geometry ────────────────────────────────────────────────────────────────
|
||||
|
||||
// The config file is untrusted: hand-edited, corrupt, or written by a build that
|
||||
// disagrees with this one. It is read in the MAIN process, where a TypeError is not
|
||||
// a bad pane — it is the app failing to start.
|
||||
function savedPaneMap(): Record<string, unknown> {
|
||||
const map = getDesktopConfig().paneWindows;
|
||||
if (!map || typeof map !== 'object' || Array.isArray(map)) return {};
|
||||
return map as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function savedFor(paneId: string): SavedPaneWindow {
|
||||
const saved = savedPaneMap();
|
||||
// Own-property check: a polluted or hand-edited config would otherwise hand back
|
||||
// a value off the prototype chain for a pane that was never saved at all.
|
||||
if (!Object.prototype.hasOwnProperty.call(saved, paneId)) return {};
|
||||
const entry = saved[paneId];
|
||||
// `{"camera_director": null}` passes the own-property check and then explodes on
|
||||
// `saved.bounds`. Anything that is not an object degrades to "nothing saved",
|
||||
// which is exactly what an unreadable entry means.
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return {};
|
||||
return entry as SavedPaneWindow;
|
||||
}
|
||||
|
||||
// A pane id reaches us from the RENDERER (it is the tail of the frame name a
|
||||
// window.open() supplied), and it is used as a key in the persisted map. So it is
|
||||
// untrusted input in key position: `__proto__` and friends are not ids, they are a
|
||||
// way to mutate Object.prototype from a plugin.
|
||||
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
function isUnsafePaneId(paneId: string): boolean {
|
||||
return UNSAFE_KEYS.has(paneId);
|
||||
}
|
||||
|
||||
function persist(paneId: string, patch: SavedPaneWindow): void {
|
||||
if (isUnsafePaneId(paneId)) return;
|
||||
try {
|
||||
// setDesktopConfig merges shallowly, so paneWindows must be
|
||||
// read-modify-written or one pane's save would drop every other pane's.
|
||||
//
|
||||
// Built on a null-prototype object: whatever is in the config file (hand
|
||||
// edited, corrupt, or written by an older build) cannot smuggle a prototype
|
||||
// into a map we then write keys onto.
|
||||
const all: Record<string, SavedPaneWindow> = Object.create(null);
|
||||
const saved = savedPaneMap();
|
||||
for (const key of Object.keys(saved)) {
|
||||
if (isUnsafePaneId(key)) continue;
|
||||
const entry = saved[key];
|
||||
// Don't carry a corrupt entry forward. Spreading `null` into the patch
|
||||
// below would be silently fine; writing it back out would keep a value
|
||||
// that crashes savedFor() on the next launch forever.
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
all[key] = entry as SavedPaneWindow;
|
||||
}
|
||||
all[paneId] = { ...(all[paneId] ?? {}), ...patch };
|
||||
setDesktopConfig({ paneWindows: { ...all } });
|
||||
} catch (err) {
|
||||
console.warn(`[panes] failed to persist geometry for ${paneId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Everything we remember about a pane window, read in one place so the debounced
|
||||
// save, the flush-on-close and the flush-on-quit can never disagree about what
|
||||
// "remembered" means. alwaysOnTop is in here because it was being RESTORED and never
|
||||
// written — a setting that could only ever be turned on by hand-editing the config.
|
||||
function snapshot(win: BrowserWindow): SavedPaneWindow {
|
||||
return {
|
||||
bounds: { ...win.getNormalBounds(), maximized: false },
|
||||
alwaysOnTop: win.isAlwaysOnTop(),
|
||||
};
|
||||
}
|
||||
|
||||
// setDesktopConfig is writeFileSync + renameSync. Wiring that straight to 'moved'
|
||||
// and 'resized' means a synchronous disk write for every frame of a drag — on
|
||||
// macOS, dozens per second, in the main process, where they block everything else.
|
||||
// Write once the gesture settles instead.
|
||||
const GEOMETRY_SAVE_DEBOUNCE_MS = 400;
|
||||
const saveTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
function persistSoon(paneId: string, patch: () => SavedPaneWindow | null): void {
|
||||
clearTimeout(saveTimers.get(paneId));
|
||||
saveTimers.set(paneId, setTimeout(() => {
|
||||
saveTimers.delete(paneId);
|
||||
const value = patch();
|
||||
if (value) persist(paneId, value);
|
||||
}, GEOMETRY_SAVE_DEBOUNCE_MS));
|
||||
}
|
||||
|
||||
// Debouncing means the last move can still be in flight when the window goes. Write
|
||||
// it out NOW, while the window is alive enough to be measured — otherwise a user who
|
||||
// nudges a pane and closes it a moment later loses the position they just chose,
|
||||
// which is exactly the thing remembered geometry exists to prevent.
|
||||
function flushGeometry(win: BrowserWindow, paneId: string): void {
|
||||
const timer = saveTimers.get(paneId);
|
||||
if (timer) { clearTimeout(timer); saveTimers.delete(paneId); }
|
||||
if (win.isDestroyed()) return;
|
||||
persist(paneId, snapshot(win));
|
||||
}
|
||||
|
||||
// ── Adoption ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function paneIdFromFrameName(frameName: string): string | null {
|
||||
if (!frameName || !frameName.startsWith(FRAME_PREFIX)) return null;
|
||||
const id = frameName.slice(FRAME_PREFIX.length);
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
// Called from main.ts's did-create-window when the renderer pops a pane out.
|
||||
export function adoptPaneWindow(win: BrowserWindow, paneId: string): void {
|
||||
windows.set(paneId, win);
|
||||
|
||||
const saved = savedFor(paneId);
|
||||
const restored = sanitizeWindowBounds(
|
||||
saved.bounds,
|
||||
screen.getAllDisplays().map((d) => d.workArea),
|
||||
// The size window.open() asked for is the fallback for a pane that has
|
||||
// never been opened before; the saved bounds win once it has.
|
||||
{ ...PANE_SIZING, defaultWidth: win.getBounds().width, defaultHeight: win.getBounds().height },
|
||||
);
|
||||
if (restored.x !== undefined && restored.y !== undefined) {
|
||||
win.setBounds({ x: restored.x, y: restored.y, width: restored.width, height: restored.height });
|
||||
} else {
|
||||
win.setSize(restored.width, restored.height);
|
||||
}
|
||||
win.setMinimumSize(PANE_SIZING.minWidth, PANE_SIZING.minHeight);
|
||||
if (saved.alwaysOnTop === true) win.setAlwaysOnTop(true);
|
||||
|
||||
// A pane is a companion to the app, not an entry to it: keep it off the taskbar
|
||||
// so it never masquerades as a second fee[dB]ack.
|
||||
win.setSkipTaskbar(true);
|
||||
|
||||
// Persist on move/resize, not only on close — a pane window can outlive the app
|
||||
// in a crash, and the whole point of remembering geometry is that you never
|
||||
// place it twice.
|
||||
const save = (): void => {
|
||||
persistSoon(paneId, () => (win.isDestroyed() ? null : snapshot(win)));
|
||||
};
|
||||
win.on('moved', save);
|
||||
win.on('resized', save);
|
||||
|
||||
// Minimize sends a pane to the tray, not the taskbar. Panes are small and
|
||||
// numerous; a taskbar full of them is noise, and the tray already lists them.
|
||||
// Electron's 'minimize' is not cancellable here (the listener takes no event),
|
||||
// so we hide right after rather than preventing it — and the window is
|
||||
// skipTaskbar, so there is no animation to see.
|
||||
win.on('minimize', () => {
|
||||
win.hide();
|
||||
refreshTray();
|
||||
});
|
||||
|
||||
// 'close' fires while the window still exists; 'closed' after it is gone. The
|
||||
// final geometry can only be read from the former.
|
||||
win.on('close', () => flushGeometry(win, paneId));
|
||||
|
||||
win.on('closed', () => {
|
||||
windows.delete(paneId);
|
||||
refreshTray();
|
||||
// No IPC needed to tell the renderer: it opened this window itself and holds
|
||||
// the WindowProxy, so it already knows — and it has to, because its element
|
||||
// is inside and must be brought home.
|
||||
});
|
||||
|
||||
refreshTray();
|
||||
}
|
||||
|
||||
export function closeAllPanes(): void {
|
||||
// destroy() does NOT fire 'close', so the flush wired to that event never runs on
|
||||
// this path — and the debounced save may still be pending. Move a pane, quit two
|
||||
// seconds later, and its position would be gone. Flush every pane first.
|
||||
windows.forEach((win, paneId) => flushGeometry(win, paneId));
|
||||
|
||||
// Called when the main window goes. A pane window holds a DOM node belonging to
|
||||
// the main window's document — with the main window gone there is nothing left
|
||||
// to dock it back into. And worse: a pane HIDDEN in the tray is still an open
|
||||
// window, so leaving one behind would stop `window-all-closed` from ever firing
|
||||
// and the app would linger as an invisible process.
|
||||
Array.from(windows.values()).forEach((win) => { if (!win.isDestroyed()) win.destroy(); });
|
||||
windows.clear();
|
||||
}
|
||||
|
||||
// ── Tray ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The renderer's last known pane registry. The tray menu is a VIEW of it, never a
|
||||
// second copy — main has no idea what a pane contains, and does not need one.
|
||||
let lastSync: TrayPane[] = [];
|
||||
|
||||
function refreshTray(): void {
|
||||
setTrayPanes(lastSync.map((p) => {
|
||||
const win = windows.get(p.id);
|
||||
return {
|
||||
...p,
|
||||
// "open", to the tray, means "has a visible window". A pane docked inside
|
||||
// the main window is not something the tray can usefully show or hide.
|
||||
open: !!win && !win.isDestroyed() && win.isVisible(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
// A pane is sent to the tray by MINIMIZING it and then hiding it — and hiding a
|
||||
// minimized window does not un-minimize it. So show() alone would restore a window
|
||||
// that is still minimized: present, but not on screen, which reads as the tray
|
||||
// being broken. Always restore first.
|
||||
function reveal(win: BrowserWindow): void {
|
||||
if (win.isDestroyed()) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
}
|
||||
|
||||
export function togglePaneWindow(paneId: string): boolean {
|
||||
const win = windows.get(paneId);
|
||||
if (!win || win.isDestroyed()) return false; // not open → only the renderer can open it
|
||||
if (win.isVisible() && !win.isMinimized()) win.hide(); else reveal(win);
|
||||
refreshTray();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function showAllPaneWindows(): void {
|
||||
windows.forEach((win) => { if (!win.isDestroyed()) reveal(win); });
|
||||
refreshTray();
|
||||
}
|
||||
|
||||
export function hideAllPaneWindows(): void {
|
||||
windows.forEach((win) => { if (!win.isDestroyed() && win.isVisible()) win.hide(); });
|
||||
refreshTray();
|
||||
}
|
||||
|
||||
// ── Wiring ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function initPaneHosts(deps: { getMainWindow: () => BrowserWindow | null }): void {
|
||||
getMainWindow = deps.getMainWindow;
|
||||
|
||||
// The renderer pushes its registry whenever a pane is registered, opened or
|
||||
// closed, so the tray can list panes it otherwise knows nothing about.
|
||||
// Fire-and-forget: the tray is a view of the renderer's truth.
|
||||
//
|
||||
// Only the MAIN window's truth, though. Pane windows are same-origin top-level
|
||||
// frames, so preload.ts's isMainFrame gate gives them the bridge too — meaning a
|
||||
// pane window (or any allowed pop-up) could send pane:sync and overwrite the
|
||||
// tray's registry, most simply by pushing an empty list and emptying the menu.
|
||||
// Only one renderer owns the pane registry; accept it from that one only.
|
||||
ipcMain.on(IPC_PANE_SYNC, (event, panes: unknown) => {
|
||||
const main = getMainWindow();
|
||||
if (!main || main.isDestroyed() || event.sender !== main.webContents) {
|
||||
console.warn('[panes] ignoring pane:sync from a webContents that is not the main window');
|
||||
return;
|
||||
}
|
||||
lastSync = Array.isArray(panes)
|
||||
? panes.filter((p): p is TrayPane => !!p && typeof p.id === 'string' && typeof p.title === 'string')
|
||||
: [];
|
||||
refreshTray();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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;
|
||||
// Returns true if it owned a window for this pane and toggled it; false if it
|
||||
// did not, in which case only the renderer can decide what opening it means.
|
||||
toggleWindow: (paneId: string) => boolean;
|
||||
showAll: () => void;
|
||||
hideAll: () => void;
|
||||
}
|
||||
|
||||
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. build:ts copies
|
||||
// resources/icons/icon.ico -> tray.ico and resources/icons/32x32.png -> tray.png.
|
||||
//
|
||||
// macOS additionally wants a monochrome TEMPLATE image, which a 32px colour PNG
|
||||
// is not — so the tray icon will render in colour there rather than adapting to
|
||||
// light/dark menu bars. Acceptable, and far 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: () => {
|
||||
// Let toggleWindow() be the authority, rather than asking "do we have a
|
||||
// window?" and then acting on the answer. Windows are destroyed
|
||||
// asynchronously, so between the question and the act the answer can go
|
||||
// stale — and the click would land on nothing and silently do nothing.
|
||||
//
|
||||
// If it toggled, we're done. If it didn't, we never had that window (or
|
||||
// just lost it), and only the renderer can decide what opening the pane
|
||||
// means — it might belong in the dock, and its element lives there.
|
||||
if (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;
|
||||
}
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
IPC_MAINTENANCE_GET_PATHS,
|
||||
IPC_MAINTENANCE_RESET,
|
||||
IPC_MAINTENANCE_RESTART,
|
||||
IPC_PANE_SYNC,
|
||||
IPC_PANE_EVENT_TOGGLE,
|
||||
} from './ipc-channels';
|
||||
|
||||
// Auto-update channel + event payloads. Kept here (rather than re-exported
|
||||
@@ -578,6 +580,21 @@ const feedBackDesktopApi = {
|
||||
power: {
|
||||
setScreenAwake: (keep: boolean) => ipcRenderer.invoke(IPC_POWER_SET_SCREEN_AWAKE, keep),
|
||||
},
|
||||
// Detachable panes. The renderer opens its own pane windows with window.open()
|
||||
// — it moves a live DOM node into them and needs a handle on the new document
|
||||
// to do it — and Electron turns that into a real BrowserWindow, which main
|
||||
// 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 registry the tray needs, and the tray asking for a pane.
|
||||
panes: {
|
||||
sync: (panes: Array<{ id: string; title: string; icon?: string; open?: boolean }>) =>
|
||||
ipcRenderer.send(IPC_PANE_SYNC, panes),
|
||||
onToggle: (callback: (paneId: string) => void) => {
|
||||
const listener = (_event: unknown, payload: { paneId: string }) => callback(payload.paneId);
|
||||
ipcRenderer.on(IPC_PANE_EVENT_TOGGLE, listener);
|
||||
return () => ipcRenderer.removeListener(IPC_PANE_EVENT_TOGGLE, listener);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (isMainFrame) {
|
||||
|
||||
@@ -38,6 +38,17 @@ interface DesktopConfig {
|
||||
// Last main-window geometry, restored (after sanitization against the
|
||||
// current display layout) on next launch — see window-bounds.ts.
|
||||
windowBounds?: SavedWindowBounds;
|
||||
// Per-pane pop-out window geometry, keyed by pane id, plus its always-on-top
|
||||
// flag. Sanitized against the display layout on restore, exactly like
|
||||
// windowBounds — see pane-hosts.ts. Lives in the desktop config rather than
|
||||
// the renderer's localStorage because localStorage is shared with the pane
|
||||
// windows themselves (same origin), and a second writer there would race.
|
||||
paneWindows?: Record<string, SavedPaneWindow>;
|
||||
}
|
||||
|
||||
export interface SavedPaneWindow {
|
||||
bounds?: SavedWindowBounds;
|
||||
alwaysOnTop?: boolean;
|
||||
}
|
||||
|
||||
function configPath(): string {
|
||||
|
||||
@@ -40,12 +40,45 @@ function isFiniteNumber(v: unknown): v is number {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
|
||||
// Size floor + fallback size. The main window's values are the defaults, so
|
||||
// every existing call site behaves exactly as before; a pane window passes its
|
||||
// own, because a 380x560 pane clamped to the main window's 800x600 floor would
|
||||
// be silently inflated into something three times its intended size.
|
||||
export interface WindowSizing {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
defaultWidth: number;
|
||||
defaultHeight: number;
|
||||
}
|
||||
|
||||
const MAIN_WINDOW_SIZING: WindowSizing = {
|
||||
minWidth: MIN_WIDTH,
|
||||
minHeight: MIN_HEIGHT,
|
||||
defaultWidth: DEFAULT_WIDTH,
|
||||
defaultHeight: DEFAULT_HEIGHT,
|
||||
};
|
||||
|
||||
// Validate saved bounds against the current display layout. Untrusted input
|
||||
// (hand-edited/corrupt config, unplugged monitor, resolution change) degrades
|
||||
// to defaults rather than producing an off-screen or absurd window. Omitted
|
||||
// x/y means "let Electron center the window".
|
||||
export function sanitizeWindowBounds(saved: unknown, displays: DisplayRect[]): RestoredWindowBounds {
|
||||
const defaults: RestoredWindowBounds = { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, maximized: false };
|
||||
export function sanitizeWindowBounds(
|
||||
saved: unknown,
|
||||
displays: DisplayRect[],
|
||||
sizing: WindowSizing = MAIN_WINDOW_SIZING,
|
||||
): RestoredWindowBounds {
|
||||
// The floor applies to the FALLBACK too, not just to saved bounds.
|
||||
//
|
||||
// The min clamp below only runs when `saved` parses. So a caller whose defaults
|
||||
// are smaller than its own minimums would get a window under the floor on
|
||||
// exactly the paths where nothing is saved — first launch, or a corrupt config —
|
||||
// and a perfectly sized one everywhere else. That is the worst shape a bug can
|
||||
// have: invisible in the common case, and only in front of a new user.
|
||||
const defaults: RestoredWindowBounds = {
|
||||
width: Math.max(sizing.defaultWidth, sizing.minWidth),
|
||||
height: Math.max(sizing.defaultHeight, sizing.minHeight),
|
||||
maximized: false,
|
||||
};
|
||||
if (displays.length === 0) return defaults;
|
||||
|
||||
const b = saved as SavedWindowBounds | undefined;
|
||||
@@ -59,8 +92,8 @@ export function sanitizeWindowBounds(saved: unknown, displays: DisplayRect[]): R
|
||||
// display's workArea (window bigger than any screen → shrink to fit).
|
||||
const maxW = Math.max(...displays.map((d) => d.width));
|
||||
const maxH = Math.max(...displays.map((d) => d.height));
|
||||
const width = Math.min(Math.max(Math.round(b.width), MIN_WIDTH), maxW);
|
||||
const height = Math.min(Math.max(Math.round(b.height), MIN_HEIGHT), maxH);
|
||||
const width = Math.min(Math.max(Math.round(b.width), sizing.minWidth), maxW);
|
||||
const height = Math.min(Math.max(Math.round(b.height), sizing.minHeight), maxH);
|
||||
|
||||
// Trust the position only if the window meaningfully overlaps some
|
||||
// display. Negative coordinates are valid multi-monitor layouts — this is
|
||||
|
||||
@@ -57,3 +57,56 @@ test('fractional coordinates are rounded to integers', () => {
|
||||
const out = sanitizeWindowBounds({ x: 10.6, y: 20.4, width: 1200.5, height: 800.2 }, [PRIMARY]);
|
||||
assert.deepEqual(out, { x: 11, y: 20, width: 1201, height: 800, maximized: false });
|
||||
});
|
||||
|
||||
// ── Custom sizing (pane windows) ────────────────────────────────────────────
|
||||
//
|
||||
// sanitizeWindowBounds grew a `sizing` parameter so pane pop-outs could be small.
|
||||
// Without it, a 380×560 pane restored through the MAIN window's 800×600 floor would
|
||||
// be silently inflated to 800×600 — three times the size the plugin asked for. The
|
||||
// default argument keeps every existing call site byte-for-byte identical, so these
|
||||
// tests exist to pin the override itself, which is the part nothing else covers.
|
||||
|
||||
const PANE_SIZING = { minWidth: 240, minHeight: 180, defaultWidth: 380, defaultHeight: 560 };
|
||||
|
||||
test('custom sizing: a small pane is NOT inflated to the main window floor', () => {
|
||||
const out = sanitizeWindowBounds({ x: 100, y: 100, width: 380, height: 560 }, [PRIMARY], PANE_SIZING);
|
||||
assert.deepEqual(out, { x: 100, y: 100, width: 380, height: 560, maximized: false });
|
||||
});
|
||||
|
||||
test('custom sizing: the min clamp uses the override, not MIN_WIDTH/MIN_HEIGHT', () => {
|
||||
// Below the pane minimum (240×180) — clamped up to it, and nowhere near 800×600.
|
||||
const out = sanitizeWindowBounds({ x: 10, y: 10, width: 50, height: 20 }, [PRIMARY], PANE_SIZING);
|
||||
assert.deepEqual(out, { x: 10, y: 10, width: 240, height: 180, maximized: false });
|
||||
});
|
||||
|
||||
test('custom sizing: missing/corrupt bounds fall back to the override defaults', () => {
|
||||
assert.deepEqual(
|
||||
sanitizeWindowBounds(undefined, [PRIMARY], PANE_SIZING),
|
||||
{ width: 380, height: 560, maximized: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
sanitizeWindowBounds({ x: 'nope', y: 0, width: 380, height: 560 }, [PRIMARY], PANE_SIZING),
|
||||
{ width: 380, height: 560, maximized: false },
|
||||
);
|
||||
});
|
||||
|
||||
test('omitting sizing keeps the main window behaviour exactly as before', () => {
|
||||
// The whole point of the default argument: existing call sites must not shift.
|
||||
const out = sanitizeWindowBounds({ x: 10, y: 10, width: 50, height: 20 }, [PRIMARY]);
|
||||
assert.deepEqual(out, { x: 10, y: 10, width: MIN_WIDTH, height: MIN_HEIGHT, maximized: false });
|
||||
});
|
||||
|
||||
test('custom sizing: defaults below the configured minimum are clamped up', () => {
|
||||
// A caller whose defaults undercut its own floor. The min clamp only runs on
|
||||
// saved bounds, so without clamping the fallback too, the floor would hold
|
||||
// everywhere EXCEPT first launch and a corrupt config — i.e. only for new users.
|
||||
const silly = { minWidth: 240, minHeight: 180, defaultWidth: 100, defaultHeight: 50 };
|
||||
assert.deepEqual(
|
||||
sanitizeWindowBounds(undefined, [PRIMARY], silly),
|
||||
{ width: 240, height: 180, maximized: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
sanitizeWindowBounds({ x: 0, y: 0, width: 'bad', height: 'bad' }, [PRIMARY], silly),
|
||||
{ width: 240, height: 180, maximized: false },
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user