mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-07-19 19:31:31 +00:00
feat(panes): pane pop-out windows + the system tray
feedBack core gained a pane system (window.feedBack.panes): live UI — a mixer, a camera rig, a readout — authored once and hostable anywhere. In a browser it pops out via window.open(). This gives it the desktop treatment: a real BrowserWindow that remembers where you put it, can float above everything, and lives in the system tray. First Tray in the app. It exists because a popped-out pane is furniture: you want it out of the way while you play and back instantly when you don't — not hunted for behind the main window, and not cluttering the taskbar. Minimizing a pane sends it to the tray; the tray menu lists every pane with a checkmark and toggles it. ## The renderer owns the truth Main never looks inside a pane. It owns OS surfaces only — windows and their geometry — and learns what panes exist from a `pane:sync` push. The tray menu is a VIEW of the renderer's registry, not a second copy of it. When the tray toggles a pane it has no window for, it asks the renderer, because only the renderer knows what opening one means (it might belong in the dock). ## The pane window loads OUR origin, and that is load-bearing A pane is fed over BroadcastChannel, which only reaches windows in the same Chromium instance and origin. Push the URL anywhere else and the pane opens looking perfect and never updates again. So `pane:open` validates the URL against the same origin predicate the navigation guards use (makeRendererOriginPredicate) and refuses anything else outright — which also means we can never open arbitrary web content with the full preload bridge attached. It is the same reason main.ts's setWindowOpenHandler answers same-origin URLs with `allow` rather than `deny` + openExternal. ## Details that bite - sanitizeWindowBounds hard-floored at the MAIN window's 800x600. A 380x560 pane restored through it would be silently inflated threefold. It now takes a WindowSizing; the main window passes its old values as the default, so every existing call site and the existing test are byte-for-byte unchanged. - Pane geometry lives in the DESKTOP config, not the renderer's localStorage — localStorage is shared with the pane windows themselves (same origin), so a second writer there would race. setDesktopConfig merges shallowly, so paneWindows is read-modify-written or one pane's save would drop the rest. - Pane windows are destroyed when the main window closes. Without the renderer there is nothing on the other end of their channel, so they would sit showing a frozen playhead forever — and a pane HIDDEN in the tray is still an open window, which would stop `window-all-closed` from ever firing and leave the app running as an invisible process. - Geometry is persisted on move/resize, not only on close: a pane window can outlive the app in a crash, and the entire point is that you never place it twice. - Electron's 'minimize' is not cancellable here (the listener takes no event), so a pane hides right after minimizing rather than preventing it. The window is skipTaskbar, so there is no animation to see. - The tray icon is copied to dist/main/ by build:ts, the same trick splash.html and spinner.json use — so __dirname resolves it identically in dev and inside a packaged asar, with no app.isPackaged branch and nothing added to electron-builder's extraResources. An unreadable icon logs and skips the tray rather than creating an invisible one whose menu no one can ever reach. Needs the matching core change (got-feedback/feedback#928), which registers the `desktop` host when this bridge is present and falls back to a browser pop-up when it isn't. An older core simply never calls these channels. Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
parent
ce242a6bcf
commit
39251f3d12
@ -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,27 @@ 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). The renderer owns
|
||||
// the truth — which panes exist, which are open, and what goes in them; the main
|
||||
// process owns the OS surfaces: one BrowserWindow per popped-out pane, their
|
||||
// remembered geometry, and the system tray.
|
||||
//
|
||||
// The pane window loads <renderer origin>/pane, so it shares the renderer's
|
||||
// BroadcastChannel scope and talks to the app over the same channel a browser
|
||||
// pop-out would. Main never sees a pane's contents.
|
||||
export const IPC_PANE_OPEN = 'pane:open' as const;
|
||||
export const IPC_PANE_CLOSE = 'pane:close' as const;
|
||||
export const IPC_PANE_FOCUS = 'pane:focus' as const;
|
||||
export const IPC_PANE_SET_ALWAYS_ON_TOP = 'pane:setAlwaysOnTop' as const;
|
||||
// The renderer pushes its pane registry up whenever it changes, so the tray menu
|
||||
// can list panes it otherwise knows nothing about.
|
||||
export const IPC_PANE_SYNC = 'pane:sync' as const;
|
||||
|
||||
// One-way pushes, main → renderer.
|
||||
// A pane window the user closed (or that crashed): the renderer must close the
|
||||
// pane so the chip's hidden dialog comes back.
|
||||
export const IPC_PANE_EVENT_CLOSED = 'pane:closed' as const;
|
||||
// The tray asked for a pane to be opened or closed. The renderer decides what
|
||||
// that means and calls back through pane:open / pane:close.
|
||||
export const IPC_PANE_EVENT_TOGGLE = 'pane:toggle' as const;
|
||||
|
||||
@ -126,6 +126,8 @@ import * as updateManager from './update-manager';
|
||||
import type { UpdateChannel } from './update-manager';
|
||||
import { installAppMenu } from './app-menu';
|
||||
import { sanitizeWindowBounds, MIN_WIDTH, MIN_HEIGHT } from './window-bounds';
|
||||
import { initPaneHosts, closeAllPanes } from './pane-hosts';
|
||||
import { initTray, destroyTray } from './pane-tray';
|
||||
|
||||
// Linux: enable Chromium's PipeWire capturer feature so getUserMedia can see
|
||||
// audio devices on PipeWire-only distros (Fedora 36+, recent Ubuntu, Arch).
|
||||
@ -785,6 +787,13 @@ function createWindow(port: number): void {
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
// Pane windows cannot outlive the window that feeds them: without the
|
||||
// renderer there is nothing on the other end of their BroadcastChannel,
|
||||
// so they would sit there showing a frozen playhead forever. Worse, a
|
||||
// pane HIDDEN in the tray is still an open window — leaving one behind
|
||||
// would stop `window-all-closed` from ever firing and the app would
|
||||
// linger as an invisible process.
|
||||
closeAllPanes();
|
||||
});
|
||||
|
||||
// Dev tools in development
|
||||
@ -1167,6 +1176,19 @@ async function startup(): Promise<void> {
|
||||
// Create the main window
|
||||
createWindow(port);
|
||||
|
||||
// Detachable panes: real BrowserWindows for popped-out panes, plus the tray
|
||||
// that lists them. Must come after createWindow — the pane host and the tray
|
||||
// both reach the renderer through mainWindow, and Tray requires a ready app.
|
||||
// The origin predicate is the same one the navigation guards use, so a pane
|
||||
// window can only ever load OUR renderer, never arbitrary web content with
|
||||
// the preload bridge attached.
|
||||
initPaneHosts({
|
||||
getMainWindow: () => mainWindow,
|
||||
isRendererOrigin: makeRendererOriginPredicate(port),
|
||||
webPreferences: rendererWebPreferences,
|
||||
});
|
||||
initTray({ getMainWindow: () => mainWindow });
|
||||
|
||||
// Install our application menu (replaces Electron's default so View →
|
||||
// Zoom In also accepts the unshifted Ctrl+= key — see app-menu.ts).
|
||||
installAppMenu();
|
||||
@ -1373,6 +1395,7 @@ function shutdown(): void {
|
||||
try {
|
||||
console.log('[main] Shutting down...');
|
||||
} catch { /* console may already be gone mid-teardown */ }
|
||||
destroyTray();
|
||||
powerAwakeRenderers.clear();
|
||||
syncPowerBlocker();
|
||||
updateManager.shutdown();
|
||||
|
||||
268
src/main/pane-hosts.ts
Normal file
268
src/main/pane-hosts.ts
Normal file
@ -0,0 +1,268 @@
|
||||
// Pane pop-out windows.
|
||||
//
|
||||
// feedBack core has a pane system (window.feedBack.panes): live UI — a mixer, a
|
||||
// camera rig, a readout — authored once and hostable anywhere. In a plain
|
||||
// browser it pops out via window.open(). Here it gets a real BrowserWindow, with
|
||||
// remembered geometry, always-on-top, and a system tray (pane-tray.ts).
|
||||
//
|
||||
// Division of labour: THE RENDERER OWNS THE TRUTH. It knows which panes exist,
|
||||
// which are open, and what is in them. This module owns OS surfaces only —
|
||||
// windows and their geometry. It never looks inside a pane.
|
||||
//
|
||||
// The window loads `<renderer origin>/pane?...`, which matters for one specific
|
||||
// reason: a pane is fed over BroadcastChannel, and BroadcastChannel only reaches
|
||||
// windows in the same Chromium instance and origin. Push the URL to the system
|
||||
// browser and the pane opens looking perfect and never updates again. That is
|
||||
// also why main.ts's setWindowOpenHandler answers same-origin URLs with
|
||||
// `action: 'allow'` rather than `deny` + shell.openExternal.
|
||||
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||
import {
|
||||
IPC_PANE_OPEN,
|
||||
IPC_PANE_CLOSE,
|
||||
IPC_PANE_FOCUS,
|
||||
IPC_PANE_SET_ALWAYS_ON_TOP,
|
||||
IPC_PANE_SYNC,
|
||||
IPC_PANE_EVENT_CLOSED,
|
||||
} from './ipc-channels';
|
||||
import { sanitizeWindowBounds, type WindowSizing } from './window-bounds';
|
||||
import { getDesktopConfig, setDesktopConfig, type SavedPaneWindow } from './soundfont-manager';
|
||||
import { setTrayPanes, type TrayPane } from './pane-tray';
|
||||
|
||||
// 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: 260,
|
||||
minHeight: 200,
|
||||
defaultWidth: 380,
|
||||
defaultHeight: 560,
|
||||
};
|
||||
|
||||
interface PaneOpenRequest {
|
||||
paneId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const windows = new Map<string, BrowserWindow>();
|
||||
let getMainWindow: () => BrowserWindow | null = () => null;
|
||||
let isRendererOrigin: (url: string) => boolean = () => false;
|
||||
|
||||
function notifyRenderer(channel: string, payload: unknown): void {
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) win.webContents.send(channel, payload);
|
||||
}
|
||||
|
||||
// ── Geometry ────────────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Windows ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function openPane(req: PaneOpenRequest, webPreferences: Electron.WebPreferences): boolean {
|
||||
// IPC is untyped at runtime. Validate before handing anything to
|
||||
// BrowserWindow — and above all, refuse a URL that is not the renderer's own
|
||||
// origin, or we would be opening arbitrary web content with the full preload
|
||||
// bridge attached.
|
||||
if (typeof req?.paneId !== 'string' || !req.paneId) return false;
|
||||
if (typeof req?.url !== 'string' || !isRendererOrigin(req.url)) {
|
||||
console.warn(`[panes] refusing to open a pane at a non-renderer origin: ${String(req?.url)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = windows.get(req.paneId);
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
// Already open: show it rather than opening a second copy. A pane that is
|
||||
// hidden in the tray comes back here.
|
||||
if (!existing.isVisible()) existing.show();
|
||||
existing.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
const saved = savedFor(req.paneId);
|
||||
const restored = sanitizeWindowBounds(
|
||||
saved.bounds,
|
||||
screen.getAllDisplays().map((d) => d.workArea),
|
||||
{
|
||||
...PANE_SIZING,
|
||||
// The pane's own declared size is the fallback when it has never been
|
||||
// opened before; the saved bounds win once it has.
|
||||
defaultWidth: req.width ?? PANE_SIZING.defaultWidth,
|
||||
defaultHeight: req.height ?? PANE_SIZING.defaultHeight,
|
||||
},
|
||||
);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
x: restored.x,
|
||||
y: restored.y,
|
||||
width: restored.width,
|
||||
height: restored.height,
|
||||
minWidth: PANE_SIZING.minWidth,
|
||||
minHeight: PANE_SIZING.minHeight,
|
||||
title: req.title || 'fee[dB]ack',
|
||||
backgroundColor: '#0f172a',
|
||||
alwaysOnTop: saved.alwaysOnTop === true,
|
||||
// A pane is a companion to the app, not an entry to it: keep it off the
|
||||
// taskbar so it never masquerades as a second fee[dB]ack.
|
||||
skipTaskbar: true,
|
||||
webPreferences,
|
||||
});
|
||||
|
||||
windows.set(req.paneId, win);
|
||||
|
||||
// Persist on move/resize, not just on close — a pane window can outlive the
|
||||
// app in a crash, and the whole point of remembering geometry is that the
|
||||
// user never has to place it twice.
|
||||
const save = (): void => {
|
||||
if (win.isDestroyed()) return;
|
||||
persist(req.paneId, { bounds: { ...win.getNormalBounds(), maximized: false } });
|
||||
};
|
||||
win.on('moved', save);
|
||||
win.on('resized', save);
|
||||
|
||||
// Minimize sends the pane to the tray, not to 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 (the listener takes no
|
||||
// event), so we hide immediately after rather than preventing it — and since
|
||||
// the window is skipTaskbar there is no minimize animation to see.
|
||||
win.on('minimize', () => {
|
||||
win.hide();
|
||||
refreshTray();
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
windows.delete(req.paneId);
|
||||
// Tell the renderer, or the pane stays "open" in its registry forever —
|
||||
// and the dialog the pop-out chip hid never comes back, leaving the user
|
||||
// with no way to reach their own UI.
|
||||
notifyRenderer(IPC_PANE_EVENT_CLOSED, { paneId: req.paneId });
|
||||
refreshTray();
|
||||
});
|
||||
|
||||
void win.loadURL(req.url);
|
||||
refreshTray();
|
||||
return true;
|
||||
}
|
||||
|
||||
function closePane(paneId: string): void {
|
||||
const win = windows.get(paneId);
|
||||
windows.delete(paneId);
|
||||
if (win && !win.isDestroyed()) win.destroy();
|
||||
}
|
||||
|
||||
export function closeAllPanes(): void {
|
||||
// Called when the main window goes. A pane window can never be fed again
|
||||
// without it — and worse, a HIDDEN pane window is still an open window, so
|
||||
// leaving one behind would keep `window-all-closed` from ever firing and the
|
||||
// app would linger as an invisible process.
|
||||
Array.from(windows.keys()).forEach(closePane);
|
||||
}
|
||||
|
||||
// ── Tray ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The renderer's last known pane registry. The tray menu is built from this plus
|
||||
// the live window state, so the tray can offer panes it knows nothing else about.
|
||||
let lastSync: TrayPane[] = [];
|
||||
|
||||
function refreshTray(): void {
|
||||
setTrayPanes(lastSync.map((p) => {
|
||||
const win = windows.get(p.id);
|
||||
return {
|
||||
...p,
|
||||
// "open" from the tray's point of view 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(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Wiring ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function initPaneHosts(deps: {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
isRendererOrigin: (url: string) => boolean;
|
||||
webPreferences: Electron.WebPreferences;
|
||||
}): void {
|
||||
getMainWindow = deps.getMainWindow;
|
||||
isRendererOrigin = deps.isRendererOrigin;
|
||||
|
||||
ipcMain.handle(IPC_PANE_OPEN, (_event, req: unknown) => openPane(req as PaneOpenRequest, deps.webPreferences));
|
||||
|
||||
ipcMain.handle(IPC_PANE_CLOSE, (_event, paneId: unknown) => {
|
||||
if (typeof paneId !== 'string') return false;
|
||||
closePane(paneId);
|
||||
refreshTray();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_PANE_FOCUS, (_event, paneId: unknown) => {
|
||||
if (typeof paneId !== 'string') return false;
|
||||
const win = windows.get(paneId);
|
||||
if (!win || win.isDestroyed()) return false;
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
refreshTray();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_PANE_SET_ALWAYS_ON_TOP, (_event, paneId: unknown, value: unknown) => {
|
||||
if (typeof paneId !== 'string') return false;
|
||||
const on = value === true;
|
||||
const win = windows.get(paneId);
|
||||
if (win && !win.isDestroyed()) win.setAlwaysOnTop(on);
|
||||
persist(paneId, { alwaysOnTop: on });
|
||||
return true;
|
||||
});
|
||||
|
||||
// The renderer pushes its registry whenever a pane is registered, opened or
|
||||
// closed. Fire-and-forget: the tray is a view of the renderer's truth, never
|
||||
// a second copy of it.
|
||||
ipcMain.on(IPC_PANE_SYNC, (_event, panes: unknown) => {
|
||||
lastSync = Array.isArray(panes)
|
||||
? panes.filter((p): p is TrayPane =>
|
||||
!!p && typeof p.id === 'string' && typeof p.title === 'string')
|
||||
: [];
|
||||
refreshTray();
|
||||
});
|
||||
}
|
||||
|
||||
// Exposed for the tray: show/hide a pane window we already have.
|
||||
export function togglePaneWindow(paneId: string): boolean {
|
||||
const win = windows.get(paneId);
|
||||
if (!win || win.isDestroyed()) return false; // not open → the renderer must 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();
|
||||
}
|
||||
120
src/main/pane-tray.ts
Normal file
120
src/main/pane-tray.ts
Normal file
@ -0,0 +1,120 @@
|
||||
// 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;
|
||||
}
|
||||
@ -33,6 +33,13 @@ import {
|
||||
IPC_MAINTENANCE_GET_PATHS,
|
||||
IPC_MAINTENANCE_RESET,
|
||||
IPC_MAINTENANCE_RESTART,
|
||||
IPC_PANE_OPEN,
|
||||
IPC_PANE_CLOSE,
|
||||
IPC_PANE_FOCUS,
|
||||
IPC_PANE_SET_ALWAYS_ON_TOP,
|
||||
IPC_PANE_SYNC,
|
||||
IPC_PANE_EVENT_CLOSED,
|
||||
IPC_PANE_EVENT_TOGGLE,
|
||||
} from './ipc-channels';
|
||||
|
||||
// Auto-update channel + event payloads. Kept here (rather than re-exported
|
||||
@ -578,6 +585,39 @@ const feedBackDesktopApi = {
|
||||
power: {
|
||||
setScreenAwake: (keep: boolean) => ipcRenderer.invoke(IPC_POWER_SET_SCREEN_AWAKE, keep),
|
||||
},
|
||||
// Detachable panes. feedBack core's pane system (window.feedBack.panes) pops
|
||||
// a pane out with window.open() in a browser; here it asks for a real
|
||||
// BrowserWindow instead — one that remembers where you put it, can float
|
||||
// above everything, and lives in the system tray.
|
||||
//
|
||||
// The renderer stays the authority: it says which panes exist and what goes
|
||||
// in them, and pushes that registry up with sync() so the tray can list them.
|
||||
// Main only owns the windows. Note the pane window loads the renderer's OWN
|
||||
// origin (/pane), so it shares the BroadcastChannel the app feeds panes over.
|
||||
panes: {
|
||||
open: (req: { paneId: string; url: string; title: string; width?: number; height?: number }) =>
|
||||
ipcRenderer.invoke(IPC_PANE_OPEN, req),
|
||||
close: (paneId: string) => ipcRenderer.invoke(IPC_PANE_CLOSE, paneId),
|
||||
focus: (paneId: string) => ipcRenderer.invoke(IPC_PANE_FOCUS, paneId),
|
||||
setAlwaysOnTop: (paneId: string, value: boolean) =>
|
||||
ipcRenderer.invoke(IPC_PANE_SET_ALWAYS_ON_TOP, paneId, value),
|
||||
sync: (panes: Array<{ id: string; title: string; icon?: string; open?: boolean }>) =>
|
||||
ipcRenderer.send(IPC_PANE_SYNC, panes),
|
||||
// The user closed a pane window (or it crashed). The renderer must close
|
||||
// the pane, or the dialog its pop-out chip hid never comes back.
|
||||
onClosed: (callback: (paneId: string) => void) => {
|
||||
const listener = (_event: unknown, payload: { paneId: string }) => callback(payload.paneId);
|
||||
ipcRenderer.on(IPC_PANE_EVENT_CLOSED, listener);
|
||||
return () => ipcRenderer.removeListener(IPC_PANE_EVENT_CLOSED, listener);
|
||||
},
|
||||
// The tray asked to open/close a pane it has no window for. Only the
|
||||
// renderer knows what that means.
|
||||
onToggle: (callback: (paneId: string) => void) => {
|
||||
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,34 @@ 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 {
|
||||
const defaults: RestoredWindowBounds = { width: sizing.defaultWidth, height: sizing.defaultHeight, maximized: false };
|
||||
if (displays.length === 0) return defaults;
|
||||
|
||||
const b = saved as SavedWindowBounds | undefined;
|
||||
@ -59,8 +81,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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user