当前位置: 首页 > news >正文

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

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


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

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

ChatCrystal 的托盘做三件事:

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

创建 Tray

核心代码在electron/tray.ts

consticonPath=path.join(__dirname,"..","icon.png");consticon=nativeImage.createFromPath(iconPath).resize({width:16,height:16});tray=newTray(icon);tray.setToolTip("ChatCrystal");

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

右键菜单

constcontextMenu=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的应用名,起视觉分隔作用,让用户一眼知道这是哪个程序的托盘菜单。这个模式在桌面应用中很常见。

双击恢复

tray.on("double-click",()=>{win.show();win.focus();});

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

清理

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

exportfunctiondestroyTray():void{if(tray){tray.destroy();tray=null;}}

二、关闭窗口 ≠ 退出程序

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

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

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

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

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

还有一个容易遗漏的点:

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)。

定义数据结构

interfaceWindowState{x?:number;y?:number;width:number;height:number;isMaximized:boolean;}

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

保存位置

functiongetWindowStatePath():string{returnpath.join(app.getPath("userData"),"window-state.json");}

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

保存逻辑:

functionsaveWindowState(win:BrowserWindow):void{constisMaximized=win.isMaximized();constbounds=isMaximized?(lastNormalBounds??win.getBounds()):win.getBounds();conststate:WindowState={x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height,isMaximized,};writeFileSync(getWindowStatePath(),JSON.stringify(state));}

恢复位置

conststate=loadWindowState();// 读 JSON,失败则返回默认值 1280x800constwin=newBrowserWindow({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()返回的是整个屏幕的尺寸,而不是用户自己调整的那个大小。如果你直接保存这个值,下次恢复时窗口虽然标记为最大化,但实际尺寸已经是屏幕大小了——用户想退出最大化回到之前的自定义大小时,回不去了。

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

letlastNormalBounds:Electron.Rectangle|null=null;win.on("resize",()=>{if(!win.isMaximized()){lastNormalBounds=win.getBounds();}});win.on("move",()=>{if(!win.isMaximized()){lastNormalBounds=win.getBounds();}});

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

constbounds=isMaximized?(lastNormalBounds??win.getBounds()):win.getBounds();

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


五、多显示器处理

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

if(state.x!==undefined&&state.y!==undefined){constdisplays=screen.getAllDisplays();constvisible=displays.some((d)=>{constb=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 服务器),退出时需要清理资源。但如果清理过程卡住了怎么办?

app.on("before-quit",(e)=>{if(!isQuitting){e.preventDefault();isQuitting=true;consttimeout=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()

清理顺序:

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

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


七、单实例锁

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

constgotLock=app.requestSingleInstanceLock();if(!gotLock){app.quit();}

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

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

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

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


八、总结

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

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

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


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

http://www.gsyq.cn/news/1391923.html

相关文章:

  • 当AI学会“看“屏幕:如何用UI-TARS桌面版告别重复点击?
  • 终极网页资源捕获指南:30秒掌握猫抓扩展的完整使用技巧
  • linux文件句柄详解
  • Lovable客服系统搭建不是选型,是重构:基于217个真实客户会话日志分析出的5层对话路由逻辑设计(附Python决策树源码)
  • 融合字形与部首特征的中文零样本实体链接模型CFCE-ZEL设计与实现
  • 2026 居家轻健身 | 每周 3 小时,无痛坚持,练出紧致好状态 ✨
  • 携程任我行礼品卡回收避坑指南!认准正规平台不踩雷 - 可可收公众号
  • 行业观察|名称近似引发市场误判!百岁人饮用水与百岁山无任何隶属关联 - 中媒介
  • 硬件高效状态监测算法TCAM:嵌入式预测性维护的极简实现
  • 3分钟实现通达信缠论自动化分析:ChanlunX开源插件完整指南
  • 全国中高端陈皮/新会陈皮/陈皮采购/陈皮合作加盟生产商专题:润元兴布局大湾区广东等地深度问答 - 十大品牌榜
  • 数据库自动化:基于 MCP 让 AI 自动连接 MySQL 进行测试数据验证
  • 最新!1950-2025年全球极端气候数据集ERA5-EX(气温、降水等34种极端气候指数)
  • Vue电商商城终极指南:3步快速构建完整开源电商平台
  • ChanlunX缠论插件:让技术分析从复杂到简单的自动化革命
  • Taotoken模型广场如何辅助技术选型与快速切换
  • Lovable测试可观测性体系构建:从traceID穿透到失败根因聚类分析,7步实现MTTR缩短67%
  • 从混乱到有序:如何用MetricFlow构建可维护的数据指标系统
  • 回收奥林巴斯Olympus MX50金相显微镜
  • 猫抓Cat-Catch终极实战指南:浏览器资源嗅探扩展的架构解密与性能调优
  • IDEA2026.1中配置Codex(非官方订阅-针对国内走中转路线NewApi)
  • League Akari:基于LCU API的终极英雄联盟客户端工具箱完整指南
  • 从模型广场选型到接入观测一次搞定量身打造的AI方案
  • 戴森球计划工厂蓝图终极指南:3000+免费自动化方案彻底改变你的游戏体验
  • AI大模型开发学习路线图,零基础快速进阶!
  • 自监督图Transformer:提升深度伪造检测泛化性与可解释性的新范式
  • 图片水印工具 - 在线图片加水印工具 - 文字/图片/平铺水印,免费批量处理
  • Real-ESRGAN终极指南:如何实现专业级图像视频修复的5大核心技术
  • 2026年国产气体涡轮流量计十大品牌综合实力排名与选型指南 - 仪表品牌排行榜
  • 长期使用TaotokenTokenPlan套餐的成本控制效果分享