Windows, Menus & Tray
Custom titlebar, window state, native menus, and system tray.
The main process owns the desktop chrome — the window itself, its persisted
bounds, the native application menu, and the optional system tray. This page
maps those pieces to lib/main/ and calls out the platform differences.
The window
lib/main/app.ts (createAppWindow) builds the single primary
BrowserWindow. It ships with a custom titlebar and the secure defaults from
Architecture: sandbox: true,
contextIsolation: true, nodeIntegration: false, webSecurity: true.
The titlebar style is per-platform:
titleBarStyle: 'hiddenInset' with trafficLightPosition: { x: 12, y: 12 } —
the native traffic-light buttons float over the app's own header. In dev the
dock icon is set explicitly (app.dock.setIcon) because the running binary is
Electron's own.
titleBarStyle: 'hidden' plus a titleBarOverlay whose color and symbol color
are computed from the launch theme, with a fixed height: 39. The renderer
draws the rest of the header and window controls.
titleBarStyle: 'default' — a standard system frame. autoHideMenuBar keeps
the menu bar out of the way until Alt is pressed.
The window is created show: false and revealed on ready-to-show, after zoom
and the maximized state are restored, so there's no flash of an unstyled or
mis-sized window. backgroundColor is set from the persisted theme up front for
the same reason.
The renderer reads window metadata through Conveyor:
WindowContextProvider (app/components/window/) calls
useConveyor('window').windowInit() once and exposes width/height, platform,
and the minimizable/maximizable flags so the custom header can render the right
controls. Window actions (windowMinimize, windowMaximize, windowClose,
windowMaximizeToggle) are Conveyor calls too — see IPC.
Window state
lib/main/window-state.ts (WindowStateStore) persists window bounds and the
maximized flag to window-state.json under Electron's userData directory. On
launch it restores them; on move/resize/close it writes them back.
It is defensive about restore:
- Bounds are Zod-validated on load; a corrupt file falls back to defaults.
isBoundsValidchecks the saved rectangle still overlaps a connected display, so unplugging an external monitor can't strand the window off-screen.- A minimum size (700×500) is enforced, and a missing/invalid rectangle is re-centered on the primary display's work area.
The same store also holds lastOpenedPath for the file demo's "open last file"
action.
Native menu
lib/main/menu.ts builds the application menu with Menu.buildFromTemplate.
Menu labels come from the main-process i18n layer (t('common:menu.*')), and it
rebuilds when the locale changes.
- macOS gets the standard app menu (about/services/hide/quit) as the first item; other platforms start with File.
- View exposes zoom (Actual Size / Zoom In / Zoom Out, wired to the
main-process zoom helpers) and fullscreen. The reload/devtools items only
appear in unpackaged (
dev) builds. - Help links to Logs, About, and the homepage (opened through the audited external-URL chokepoint), plus a Check for Updates item that is disabled when no update action is provided.
The Template menu is flag-gated demo content:
// lib/main/menu.ts — [template-demo]
const templateMenu = showcaseEnabled
? { label: t('common:menu.template'), submenu: [/* Showcase, File demo */] }
: undefinedshowcaseEnabled comes from mainFeatures.showcase in
lib/main/feature-flags.ts. Keep it matched with the renderer showcase flag so
the Template menu appears exactly when the Showcase UI does — see
Showcase.
System tray
lib/main/tray.ts (TrayService) is opt-in: the tray only exists when the
user enables Minimize to tray (General settings, minimizeToTray, default
off). applySettings() creates or destroys the tray whenever that setting
changes.
- The tray icon adapts to the OS. macOS uses a monochrome template image (with
an
@2xrepresentation) that the system recolors for the menu bar; Windows and Linux swap a light/dark PNG based onnativeTheme. - The context menu offers Open Main Window, Settings, Check/Ready for Updates (the label flips when a background check finds an update), About, and Quit. It rebuilds on locale change and on update-status change.
- Tray navigation dispatches typed app intents (
appIntentService.dispatch) — the same validated route mechanism deep links use.
Close-to-tray behavior is guarded: on Windows/Linux, closing the window while
minimizeToTray is on and a tray exists hides the window instead of quitting.
On macOS the platform's own hide/quit conventions apply, and a real quit
(before-quit) always closes.
Navigation & external URLs
Two small modules keep navigation safe, and every outward link flows through one audited chokepoint:
| Concern | File | Behavior |
|---|---|---|
| In-app navigation | navigation-guard.ts | will-navigate is allowed only for the same origin as the loaded renderer (or the dev server origin in dev); anything else is prevented. |
| External links | external-url.ts | openExternal accepts only https: and mailto: URLs and opens them in the system browser via shell.openExternal; other protocols throw. |
| New windows | app.ts | setWindowOpenHandler denies every window.open, routing the URL through openExternal instead. |
The net effect: the renderer can never navigate itself to an arbitrary origin or spawn an unmanaged window. Product links open in the user's real browser.
Zoom & permissions
- Zoom (
zoom.ts) clamps the webContents zoom level to the settings range and persists it, so the View menu, the palette, and the restored window all agree on one value. - Permissions (
permissions.ts) installs a default-deny permission handler:ALLOWED_RENDERER_PERMISSIONSis empty, so the renderer is refused camera, microphone, geolocation, and every other browser permission until you explicitly add one. This is separate from the macOSNS*UsageDescriptionstrings inelectron-builder.yml, which are OS-level prompts declared at packaging time — trim the ones your app doesn't use.
