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:
K. O. A.
2026-07-12 21:35:19 -04:00
committed by GitHub
9 changed files with 2009 additions and 1400 deletions
+1 -1
View File
@@ -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",
+19
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+297
View File
@@ -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();
});
}
+144
View File
@@ -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;
}
+17
View File
@@ -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) {
+11
View File
@@ -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 {
+37 -4
View File
@@ -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
+53
View File
@@ -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 },
);
});