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.
  • isBoundsValid checks 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 */] }
  : undefined

showcaseEnabled 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 @2x representation) that the system recolors for the menu bar; Windows and Linux swap a light/dark PNG based on nativeTheme.
  • 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.

Two small modules keep navigation safe, and every outward link flows through one audited chokepoint:

ConcernFileBehavior
In-app navigationnavigation-guard.tswill-navigate is allowed only for the same origin as the loaded renderer (or the dev server origin in dev); anything else is prevented.
External linksexternal-url.tsopenExternal accepts only https: and mailto: URLs and opens them in the system browser via shell.openExternal; other protocols throw.
New windowsapp.tssetWindowOpenHandler 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_PERMISSIONS is empty, so the renderer is refused camera, microphone, geolocation, and every other browser permission until you explicitly add one. This is separate from the macOS NS*UsageDescription strings in electron-builder.yml, which are OS-level prompts declared at packaging time — trim the ones your app doesn't use.

On this page