Skip to content
曾亮懿 ZengLiangYi Blog

Electron tray and window state: hide, restore, and quit

Read ChatCrystal’s mode-aware tray, saved window bounds, and shutdown flow, including what persistence restores and which failures still need testing.

曾亮懿

曾亮懿

4 min read
Table of contents Show Hide

A desktop application must choose whether closing its window also stops its process. ChatCrystal currently hides the window in the tray. Restoring bounds, showing the window, and quitting have separate implementations. This article reads the pinned main process and tray module; their presence does not establish a complete experience across platforms. For the main/preload relationship, see Electron basics.

The menu follows the connection mode

createTray destroys the previous instance, then creates a 16×16 nativeImage and Tray. Its inputs include a mode and action callbacks, rather than one fixed localhost destination.

Mode Browser and search destination Actions always included
onboarding No connected destination; both entries are omitted Show window, reconnect, check for updates, quit
local localBaseUrl Same actions
cloud cloudBaseUrl Same actions

Search loads the destination’s /search; opening the browser uses shell.openExternal. Double-click and the show-window item call showMainWindow, which restores a minimized window before show and focus. Search directly calls show, focus, and loadURL without that explicit restore step. The entry points therefore do not all implement the same restoration sequence.

16×16 is this revision’s resize choice; it does not make every source image sharp on every platform. The Electron Tray documentation distinguishes platform events and icon requirements, and marks double-click as macOS/Windows. Linux desktop environments need separate acceptance checks. Registering an event in source does not establish identical platform behavior.

close saves state before deciding to hide

This excerpt is the actual close handler. It depends on existing main-process variables and functions; it is not a standalone application:

win.on('close', (event) => {
  saveWindowState(win);
  if (!isQuitting) {
    event.preventDefault();
    win.hide();
  }
});

For an ordinary close, isQuitting is false: the handler saves state and hides the window, leaving the service and process running. The tray quit item calls app.quit(). The first before-quit intercepts quitting and sets the flag; the second quit, after cleanup, proceeds. The window-all-closed handler does not call quit.

Hiding preserves state in the running process. It does not mean all data has reached disk. Window JSON and the knowledge database use different persistence paths; successfully reopening a hidden window does not verify database persistence.

Normal bounds and position recovery are separate checks

The file is app.getPath('userData')/window-state.json. It stores x, y, width, height, and isMaximized. Resize/move events outside maximized state update lastNormalBounds; saving a maximized window prefers that cache and otherwise falls back to getBounds.

This preserves normal bounds that have already been observed. The cache is not initialized with a value, and the state is not a complete fullscreen/minimize model. Electron also offers getNormalBounds. The current code uses a cache; an available API and an implemented behavior are different facts.

A read or JSON parse failure returns the default 1280×800, non-maximized state. After a successful read, non-finite width/height fall back to defaults and dimensions are clamped to at least 1280×800. These are the current default and minimum dimensions; the original draft’s 900×600 minimum is outdated. Save errors are swallowed: persistence is best effort, with no success notification or retry.

Position validation checks only the saved top-left point against each display’s bounds, allowing 50 of tolerance on its left and top edges. If none matches, x/y are cleared so the system chooses a position. It does not check the whole window’s intersection with workArea or clamp its dimensions to a small display. Coordinates also lack a complete runtime schema. Disconnecting a secondary display therefore does not guarantee that the entire window becomes visible.

Quitting has an order and a timeout

gracefulShutdown calls kill on an active remote-import worker and clears its reference, then awaits local service shutdown and destroys the tray. It does not wait for the worker’s exit event. See Fastify production static service for the database and HTTP shutdown order.

before-quit starts a 10-second timer that calls app.exit(1) on timeout. Whether cleanup succeeds or throws, finally clears the timer and calls quit again. While the event loop can run, the timer limits waiting for asynchronous cleanup. It cannot preempt synchronous disk writes that block the main thread, or establish that every write completed. requestSingleInstanceLock and second-instance handling prevent another window from opening for the same Electron application. They do not lock a SQLite file against simultaneous CLI and desktop writers.

Verify scenarios beyond a visible window

Using an isolated test profile, record ordinary close followed by tray restoration; minimized-window restoration through show, double-click, and search separately; restarting after maximized close and then unmaximizing; starting after disconnecting a secondary display; a malformed or unwritable state file; and cleanup that fails or exceeds 10 seconds.

These are proposed acceptance scenarios, not desktop experiments claimed by this article. Record visibility, state-file changes, and exit code separately so that one result does not stand in for the others.

Share:

Related Posts

Fastify and Electron: Desktop Startup & Shutdown Boundaries

Trace ChatCrystal local, cloud, and onboarding modes, verify CommonJS loading and port contention, and inspect window and shutdown boundaries.

曾亮懿 曾亮懿
5 min read
#ChatCrystal #Node.js #Electron

AI Conversation Formats: JSONL Events vs SQLite Snapshots

Compare 5 AI adapter formats: separate JSONL events from SQLite keys, and inspect indexed, orphan, and unknown-version chat transcripts in memory stores.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Node.js #SQLite

How Claude Code, Cursor, Copilot, and Trae Store Chats on Disk

Compare Claude Code, Cursor, Copilot, and Trae storage paths. Distinguish raw copies from exports, and verify migration retention with test manifests.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Node.js #SQLite

From writing to code

Explore ChatCrystal, developer tools and daily learning notes on my GitHub. Questions about the implementation are welcome.