---
title: "Electron tray and window state: hide, restore, and quit"
description: Read ChatCrystal’s mode-aware tray, saved window bounds, and shutdown flow, including what persistence restores and which failures still need testing.
image: "https://zengliangyi.com/og/blog/en/electron-tray-window-state.png"
---

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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/electron/main.ts) and [tray module](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/electron/tray.ts); their presence does not establish a complete experience across platforms. For the main/preload relationship, see [Electron basics](/en/blog/electron-app-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](https://www.electronjs.org/docs/latest/api/tray) 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:



```ts
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](https://www.electronjs.org/docs/latest/api/browser-window#wingetnormalbounds). 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](/en/blog/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.          曾亮懿       October 7, 2026      5 min read

#ChatCrystal

#Node.js

#Electron](/en/blog/fastify-electron-service-lifecycle)    [##  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.          曾亮懿       October 7, 2026      4 min read

#ChatCrystal

#Node.js

#SQLite](/en/blog/ai-conversation-format-divergence)    [##  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.          曾亮懿       October 7, 2026      4 min read

#ChatCrystal

#Node.js

#SQLite](/en/blog/ai-conversation-management-options)

##  From writing to code

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

[Visit my GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Electron tray and window state: hide, restore, and quit",
  "description": "Read ChatCrystal’s mode-aware tray, saved window bounds, and shutdown flow, including what persistence restores and which failures still need testing.",
  "url": "https://zengliangyi.com/en/blog/electron-tray-window-state/",
  "image": "https://zengliangyi.com/images/blog/electron-tray-window-state.svg",
  "datePublished": "2026-10-05T00:00:00.000Z",
  "dateModified": "2026-10-05T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/en/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/en/blog/electron-tray-window-state/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://zengliangyi.com/en"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Blog",
      "item": "https://zengliangyi.com/en/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Electron tray and window state: hide, restore, and quit",
      "item": "https://zengliangyi.com/en/blog/electron-tray-window-state/"
    }
  ]
}
```
