系统托盘 + 窗口状态持久化:Electron 细节

本文面向:正在打磨 Electron 应用体验的开发者。 预计阅读时间:10 分钟 最终效果:掌握系统托盘、窗口关闭行为、状态持久化、多显示器检测和优雅关闭的完整实现。


一、系统托盘:让应用在后台待命

桌面应用和网页不同,用户期望关掉窗口后程序还在------就像微信、Spotify、Slack 那样。系统托盘(Tray)就是实现这个体验的关键。

ChatCrystal 的托盘做三件事:

  1. 提供快捷入口(打开窗口、搜索知识、在浏览器中打开)
  2. 允许真正的退出
  3. 双击恢复窗口

创建 Tray

核心代码在 electron/tray.ts

ts 复制代码
const iconPath = path.join(__dirname, "..", "icon.png");
const icon = nativeImage
  .createFromPath(iconPath)
  .resize({ width: 16, height: 16 });

tray = new Tray(icon);
tray.setToolTip("ChatCrystal");

注意 resize({ width: 16, height: 16 }) 这一步。托盘图标的显示区域很小,如果不做缩放,高分辨率图标会被系统强制压缩,边缘会发糊。提前缩放到 16x16 能保证像素精确。

右键菜单

ts 复制代码
const contextMenu = Menu.buildFromTemplate([
  { label: "ChatCrystal", enabled: false },  // 纯展示,不可点击
  { type: "separator" },
  {
    label: "Open Window",
    click: () => { win.show(); win.focus(); },
  },
  {
    label: "Search Knowledge",
    click: () => {
      win.show(); win.focus();
      win.loadURL(`http://localhost:${port}/search`);
    },
  },
  {
    label: "Open in Browser",
    click: () => { shell.openExternal(`http://localhost:${port}`); },
  },
  { type: "separator" },
  {
    label: "Quit",
    click: () => { app.quit(); },
  },
]);
tray.setContextMenu(contextMenu);

这里有个设计考量:菜单第一项是 enabled: false 的应用名,起视觉分隔作用,让用户一眼知道这是哪个程序的托盘菜单。这个模式在桌面应用中很常见。

双击恢复

ts 复制代码
tray.on("double-click", () => {
  win.show();
  win.focus();
});

双击托盘图标恢复窗口,是一个符合 Windows 用户直觉的交互。注意 show() 之后还要 focus(),否则窗口可能出现在其他窗口后面。

清理

退出时调用 destroyTray() 销毁托盘图标。如果不销毁,Windows 上可能残留一个"幽灵"图标,要鼠标划过才会消失。

ts 复制代码
export function destroyTray(): void {
  if (tray) {
    tray.destroy();
    tray = null;
  }
}

二、关闭窗口 ≠ 退出程序

这是 Electron 应用最常见的设计决策:用户点窗口的关闭按钮时,应该退出程序还是隐藏到托盘?

ChatCrystal 的策略是:关闭 = 隐藏,退出 = 托盘菜单里的 Quit

ts 复制代码
let isQuitting = false;

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

isQuitting 是关键标志位。默认是 false,所以点关闭按钮只会 hide()。当用户从托盘菜单点击 Quit 时,app.quit() 会触发 before-quit 事件,把 isQuitting 设为 true,之后再触发 close 事件时就不会被拦截了。

ts 复制代码
app.on("before-quit", (e) => {
  if (!isQuitting) {
    e.preventDefault();
    isQuitting = true;
    // ... 执行优雅关闭
  }
});

还有一个容易遗漏的点:

ts 复制代码
app.on("window-all-closed", () => {
  // On Windows, don't quit when all windows closed (tray keeps running)
});

默认的 Electron 模板会在 window-all-closed 里调用 app.quit()。但对我们来说,窗口关闭只是隐藏,程序应该继续在托盘里运行,所以这里什么都不做。


三、窗口状态持久化

用户把窗口拖到副屏、调了大小,下次打开时位置和尺寸应该和上次一样。这个功能叫窗口状态持久化(Window State Persistence)。

定义数据结构

ts 复制代码
interface WindowState {
  x?: number;
  y?: number;
  width: number;
  height: number;
  isMaximized: boolean;
}

xy 是可选的------首次启动时没有保存过位置,就让系统决定默认位置。

保存位置

ts 复制代码
function getWindowStatePath(): string {
  return path.join(app.getPath("userData"), "window-state.json");
}

app.getPath("userData") 返回的是用户数据目录(Windows 上是 %APPDATA%/ChatCrystal),这是 Electron 推荐的持久化存储位置。不要用项目目录,打包后项目目录是只读的。

保存逻辑:

ts 复制代码
function saveWindowState(win: BrowserWindow): void {
  const isMaximized = win.isMaximized();
  const bounds = isMaximized
    ? (lastNormalBounds ?? win.getBounds())
    : win.getBounds();
  const state: WindowState = {
    x: bounds.x,
    y: bounds.y,
    width: bounds.width,
    height: bounds.height,
    isMaximized,
  };
  writeFileSync(getWindowStatePath(), JSON.stringify(state));
}

恢复位置

ts 复制代码
const state = loadWindowState();  // 读 JSON,失败则返回默认值 1280x800

const win = new BrowserWindow({
  width: state.width,
  height: state.height,
  x: state.x,
  y: state.y,
  minWidth: 900,
  minHeight: 600,
  show: false,  // 先隐藏,等 ready-to-show 再显示,避免白屏闪烁
  // ...
});

if (state.isMaximized) {
  win.maximize();
}

注意 show: false + ready-to-show 的组合。如果创建窗口时就显示,用户会看到一个空白窗口然后内容突然出现。先隐藏、等内容加载好再显示,体验更干净。


四、最大化状态的特殊处理

这是个很容易踩坑的地方。

假设用户把窗口最大化了,此时 win.getBounds() 返回的是整个屏幕的尺寸,而不是用户自己调整的那个大小。如果你直接保存这个值,下次恢复时窗口虽然标记为最大化,但实际尺寸已经是屏幕大小了------用户想退出最大化回到之前的自定义大小时,回不去了。

解决方案:用一个独立变量追踪窗口在非最大化时的正常尺寸。

ts 复制代码
let lastNormalBounds: Electron.Rectangle | null = null;

win.on("resize", () => {
  if (!win.isMaximized()) {
    lastNormalBounds = win.getBounds();
  }
});
win.on("move", () => {
  if (!win.isMaximized()) {
    lastNormalBounds = win.getBounds();
  }
});

保存时的逻辑就很清楚了:

ts 复制代码
const bounds = isMaximized
  ? (lastNormalBounds ?? win.getBounds())
  : win.getBounds();

如果当前是最大化状态,保存的是上一次正常状态的尺寸。这样用户双击标题栏退出最大化时,窗口能正确恢复到之前的大小。


五、多显示器处理

用户在公司用双屏,回家用单屏。如果上次关闭时窗口在副屏上,下次打开时保存的坐标可能指向一个不存在的显示器。这时候窗口会"消失"在屏幕外。

ts 复制代码
if (state.x !== undefined && state.y !== undefined) {
  const displays = screen.getAllDisplays();
  const visible = displays.some((d) => {
    const b = d.bounds;
    return (
      state.x! >= b.x - 50 &&
      state.x! < b.x + b.width &&
      state.y! >= b.y - 50 &&
      state.y! < b.y + b.height
    );
  });
  if (!visible) {
    state.x = undefined;
    state.y = undefined;
  }
}

screen.getAllDisplays() 返回当前所有显示器的信息。我们检查保存的坐标是否落在某个显示器的范围内。注意这里有一个 50 像素的容差(b.x - 50),因为窗口的标题栏可能在显示器边缘之外,这是正常状态。

如果检测到窗口不在任何可见区域内,就把 xy 清空,让系统在主显示器上分配一个默认位置。


六、优雅关闭与超时保护

Electron 应用通常内嵌了服务(比如 ChatCrystal 内嵌了 Fastify 服务器),退出时需要清理资源。但如果清理过程卡住了怎么办?

ts 复制代码
app.on("before-quit", (e) => {
  if (!isQuitting) {
    e.preventDefault();
    isQuitting = true;

    const timeout = setTimeout(() => {
      console.error("[Electron] Shutdown timed out, forcing exit");
      app.exit(1);
    }, 10000);

    gracefulShutdown()
      .catch((err) => console.error("[Electron] Shutdown error:", err))
      .finally(() => {
        clearTimeout(timeout);
        app.quit();
      });
  }
});

关键设计:

  1. e.preventDefault() 阻止默认退出,让我们手动控制流程
  2. 10 秒超时------如果清理超过 10 秒,强制退出(app.exit(1)
  3. 无论成功还是失败,最终都会调用 app.quit()

清理顺序:

ts 复制代码
async function gracefulShutdown(): Promise<void> {
  console.log("[Electron] Shutting down...");
  if (serverShutdown) {
    await serverShutdown();  // 先关服务器(刷新数据库、停止 watcher)
    serverShutdown = null;
  }
  destroyTray();  // 再销毁托盘
}

先关服务器、再销毁托盘,这个顺序很重要。如果反过来,用户会看到托盘图标消失了但进程还在跑(因为服务器关闭可能需要几秒),会以为程序卡死了。


七、单实例锁

桌面应用应该只运行一个实例。如果用户双击了两次快捷方式,不应该打开两个窗口。

ts 复制代码
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
  app.quit();
}

如果拿不到锁,说明已经有一个实例在运行,直接退出。

那已经在运行的那个实例怎么响应?监听 second-instance 事件:

ts 复制代码
app.on("second-instance", () => {
  if (mainWindow) {
    if (mainWindow.isMinimized()) mainWindow.restore();
    mainWindow.show();
    mainWindow.focus();
  }
});

把已有窗口恢复到前台。如果是最小化状态,先 restore()show()


八、总结

这些细节单独看都不复杂,但加在一起就是"能用"和"好用"的区别:

特性 作用
系统托盘 后台待命,快速访问常用功能
关闭 = 隐藏 用户关窗口不丢状态,随时恢复
isQuitting 标志 区分"隐藏"和"真正退出"
lastNormalBounds 最大化后正确恢复普通尺寸
多显示器检测 防止窗口"消失"在屏幕外
10 秒超时保护 防止退出时卡死
单实例锁 防止重复启动

如果你正在做 Electron 应用,建议把这些作为"基础体验清单"逐项实现。用户不会注意到这些细节做对了,但一定会注意到做错了。


项目地址:github.com/ZengLiangYi...

相关推荐
李铁蛋zs5 小时前
AI 前端开发 Prompt 模板库
前端·vue.js·prompt
Muen5 小时前
Swift-属性包装器
前端
qq_2518364575 小时前
基于java Web快乐岛儿童网站设计与实现
java·开发语言·前端
Crystal3285 小时前
App wgt 热更新 — 开发笔记(uniapp)
前端·uni-app·app
newAir5 小时前
前端转 AI 应用开发 · 02 | 5 分钟用 Python 调通大模型(async + 阿里云 Coding Plan)
前端·人工智能
来一碗刘肉面5 小时前
使用Tailwind CSS 创建一个新项目
前端·css
Ruihong5 小时前
VuReact v1.8.4 发布:Vue 迁移 React 编译器迎来稳定性大修,这些坑终于被填平了
前端·vue.js·react.js
竹子很安逸5 小时前
从零给 AI Agent 接入 MCP 工具生态
前端
从文处安5 小时前
「React Router v7 教程」从零到全栈,一篇搞定
前端·react.js