十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Electron+Vue3桌面应用架构改造实战:从VSCode插件到跨平台打字游戏

Electron+Vue3桌面应用架构改造实战:从VSCode插件到跨平台打字游戏 1. 项目概述为什么一个打字游戏值得做两次Electron Vue 3 桌面打字游戏实战——这个标题里藏着三个关键信号它不是玩具级 Demo而是有真实交付压力的工程它经历过一次“身份转换”从 VSCode 插件蜕变为独立桌面应用它用的是当前最主流、也最容易踩坑的技术组合。我带团队做过 7 个 Electron 项目其中 4 个是从编辑器插件起步的这个打字游戏就是典型样本最初只是给内部新人培训用的 VSCode 小插件结果用户反馈太好三个月内被自发传播到 12 家技术团队最后不得不拆出来做成独立应用。核心关键词Electron、Vue 3、VSCode、架构改造、桌面应用每一个都不是摆设——Electron 决定了跨平台打包和原生能力接入方式Vue 3 的 Composition API 和响应式系统直接决定了状态管理能否撑住高频输入场景VSCode 不是背景板而是原始运行环境它的生命周期、API 调用边界、调试机制都深度参与了第一阶段开发架构改造不是口号而是把插件里“依赖 VSCode 运行时”的耦合逻辑一刀切掉替换成 Electron 主进程/渲染进程通信模型桌面应用则意味着你得直面 Windows/macOS/Linux 三端差异、安装包体积、自动更新、菜单栏行为、托盘图标、甚至杀毒软件误报等真实世界问题。这个项目真正难的从来不是写个打字逻辑——那是 200 行代码就能搞定的事。难的是在两个完全不同的宿主环境之间保持业务逻辑 95% 复用的前提下把底层胶水层彻底重写。比如VSCode 插件里调用vscode.window.showInformationMessage()是一行代码到了 Electron 里你得判断是走主进程弹系统通知macOS 用 NSUserNotificationWindows 用 Toast Notification还是走渲染进程用 Vue 自定义弹窗还得考虑静音模式、权限申请、点击跳转行为。再比如键盘事件在 VSCode 插件里你监听onDidChangeTextEditorSelection就够了但在 Electron 独立应用里你得同时处理keydown、keypress、input三层事件还要防住 CtrlSpace 触发输入法候选框导致的事件丢失更别说 macOS 上 Cmd 键和 Windows 上 Ctrl 键的行为差异。这些细节文档不会写Stack Overflow 答案往往过时只有真正在两个环境里都跑通、都上线、都被用户骂过之后才敢说“懂了”。所以这篇不是教程是我把 37 个版本迭代、112 次构建失败、8 次用户投诉后的实操笔记全摊开给你看。2. 架构设计与思路拆解从插件到应用不是复制粘贴2.1 为什么必须重构VSCode 插件和 Electron 应用的根本差异很多人以为“把 VSCode 插件代码拷进 Electron 项目就能跑”我试过三天后删库重来。根本原因在于两者运行模型天差地别启动模型不同VSCode 插件没有独立进程它寄生在 VSCode 主进程里随编辑器启动而加载随编辑器关闭而卸载Electron 应用是独立进程有自己的主进程Node.js 环境和多个渲染进程Chromium 渲染引擎必须自己管理生命周期。API 调用层级不同VSCode 插件通过vscode全局对象调用 API所有接口都是 Promise 化、类型安全、带上下文感知比如vscode.workspace.rootPath自动指向当前打开文件夹Electron 里你要自己封装 IPC 通道主进程暴露方法渲染进程调用中间还要处理序列化限制、错误传递、超时控制。UI 渲染约束不同VSCode 插件 UI 只能用 Webview 或 QuickPick样式被严格限制在 VSCode 主题色系内不能改字体、不能加阴影、不能用 CSS 变量覆盖主题Electron 渲染进程是完整 Chromium你可以用 Tailwind、用 CSS-in-JS、用 Canvas 动画但代价是你得自己处理 DPI 缩放、暗色模式适配、窗口焦点丢失时的输入暂停。资源访问权限不同VSCode 插件默认只能读取当前工作区文件要读其他路径必须显式请求权限Electron 应用默认拥有全盘读写权限Windows 上尤其危险你得自己加沙箱、加白名单、加文件选择器校验否则一上线就被杀毒软件标为“可疑程序”。提示不要试图在 Electron 里模拟 VSCode API。我见过最蠢的方案是写个mock-vscode包把vscode.window、vscode.workspace全部 mock 成空对象——结果上线后用户反馈“打字没反应”查了一整天才发现是 mock 的vscode.env.openExternal把所有外链跳转都吞掉了而真实场景里用户需要点帮助文档跳转到浏览器。2.2 架构分层策略业务逻辑隔离是复用的唯一出路我们最终采用四层架构每层职责清晰边界不可逾越层级名称职责复用率关键约束L1Core Domain Layer核心领域层打字游戏业务逻辑单词生成规则、正确率计算、速度统计、错词记录、训练模式切换98%纯 TypeScript零依赖不 import 任何框架或平台 API只用标准 ES 模块L2Platform Abstraction Layer平台抽象层封装平台特定能力存储localStorage vs Electron Store、通知vscode.window.showInformationMessage vs Electron Notification、文件读写vscode.workspace.fs vs Electron fs-extra65%每个平台实现独立目录/platform/vscode/platform/electron通过工厂函数注入L3UI Presentation LayerUI 展示层Vue 3 组件输入框、统计面板、训练进度条、键盘可视化、设置页82%使用 Composition API Pinia状态全部来自 L1副作用如通知通过 L2 接口触发L4Runtime Integration Layer运行时集成层VSCode 插件激活逻辑 / Electron 主进程初始化0%完全隔离VSCode 版本extension.tsElectron 版本main.ts无任何共享代码这个分层不是理论设计是被现实逼出来的。比如 L1 层的TypingEngine类它只接收字符串输入、返回{ correct: number, total: number, wpm: number }对象内部不关心你是从键盘事件来、还是从测试用例来、还是从语音识别 API 来。L2 层的StorageAdapter接口定义了save(key: string, value: any)和loadT(key: string): TVSCode 实现用vscode.workspace.getConfiguration().update()Electron 实现用electron-store调用方L3完全无感。这种设计让我们在 Electron 版本上线后又快速反向输出了一个 Web 版PWA只用了 3 天就完成因为 L1 和 L3 几乎不用动L2 新增一个web目录L4 写个index.html加vite启动脚本就行。2.3 Vue 3 选型深挖Composition API 不是语法糖是架构救星Vue 2 的 Options API 在这个项目里会死得很惨。原因很简单打字游戏的状态极其碎片化——光是输入状态就有“空闲”、“正在输入”、“暂停”、“完成”、“错误锁定”五种单词状态有“未显示”、“已显示”、“已输入”、“输入错误”、“跳过”六种再加上训练模式自由打、限时、闯关、难度等级初级/中级/高级、统计维度今日/本周/历史……如果用 Options APIdata里堆 20 个响应式变量methods里塞 50 个函数watch里写 15 个监听器维护成本指数级上升。Composition API 的价值在于逻辑聚合我把“输入控制”抽成useInputManager()把“单词流管理”抽成useWordStream()把“统计计算”抽成useStatistics()每个 Hook 都是自包含的响应式模块互相之间只通过明确的 props 或事件通信。举个真实例子useInputManager()内部封装了键盘事件监听keydown/keyup/input输入法兼容处理检测compositionstart/compositionend输入缓冲区防连击、防重复提交错误锁定逻辑连续 3 次错误后锁定 2 秒暂停恢复状态机支持快捷键CtrlP暂停这些逻辑在 VSCode 插件版和 Electron 版里完全一致只是事件源不同VSCode 版监听vscode.window.onDidChangeTextEditorSelectionElectron 版监听window.addEventListener(keydown)。Hook 内部用const inputSource injectInputSource(inputSource)注入事件源L4 层决定传哪个实现L3 层完全不用知道区别。这就是 Composition API 带来的解耦能力——它让“可复用性”从口号变成可落地的代码结构。3. 核心细节解析与实操要点那些文档里找不到的坑3.1 Electron 主进程初始化别让app.whenReady()成为性能瓶颈很多教程教你在main.ts里写app.whenReady().then(() { createWindow() })这在开发环境没问题但上线后用户双击 exe 启动会明显感觉到“卡顿 1~2 秒才出窗口”。原因在于app.whenReady()等待的是 Electron 内部所有初始化完成包括 GPU 进程启动、沙箱检查、安全策略加载……而你的createWindow()里如果还做了BrowserWindow.loadFile(index.html)那整个过程就是串行阻塞的。我们的优化方案是预加载 并行初始化// main.ts let mainWindow: BrowserWindow | null null // 1. 提前创建窗口实例不显示 const createWindow () { mainWindow new BrowserWindow({ width: 1024, height: 768, show: false, // 关键先不显示 webPreferences: { preload: path.join(__dirname, preload.js), nodeIntegration: false, contextIsolation: true, sandbox: true // 强制沙箱提升安全性 } }) // 2. 立即加载 HTML后台加载不阻塞 UI mainWindow.loadFile(path.join(__dirname, ../renderer/index.html)) } // 3. 当 Electron 就绪且 HTML 加载完成再显示窗口 app.whenReady().then(() { createWindow() // 监听窗口加载完成事件 if (mainWindow) { mainWindow.webContents.on(did-finish-load, () { mainWindow?.show() // 此时才显示用户感知不到白屏 mainWindow?.focus() }) } })注意show: false必须配合did-finish-load事件否则可能在 HTML 还没解析完就显示空白窗口。我们实测这个改动让首屏时间从 1.8s 降到 0.3sWindows 10 i5-8250U。3.2 Vue 3 渲染进程通信IPC 不是万能胶要用对地方Electron 的 IPCInter-Process Communication常被滥用。新手喜欢把所有跨进程调用都塞进ipcRenderer.invoke()结果导致主进程线程阻塞Node.js 是单线程渲染进程等待超时默认 30s用户觉得卡死错误堆栈丢失IPC 错误只返回Error: Failed to fetch我们的通信原则是只传必要数据只在必要时通信。场景推荐方案原因实操示例读取用户配置electron-storewatch配置变更频率低用文件监听比 IPC 轮询高效渲染进程const store new Store(); store.watch(theme, (newValue) { /* 更新 CSS 变量 */ })发送通知main process NotificationipcRenderer.send()通知是单向、无返回值send比invoke轻量 3 倍渲染进程ipcRenderer.send(notify, { title: 完成, body: 今日训练结束 })主进程监听并创建 Notification文件读写fs-extracontextBridge暴露安全 API避免渲染进程直接调用 Node.js API防止 XSSpreload.js中contextBridge.exposeInMainWorld(api, { readFile: (path) ipcRenderer.invoke(read-file, path) })只暴露readFile不暴露writeFile键盘快捷键globalShortcut.register()ipcRenderer.send()快捷键必须在主进程注册但响应逻辑在渲染进程主进程globalShortcut.register(CommandOrControlP, () { mainWindow?.webContents.send(toggle-pause) })渲染进程监听ipcRenderer.on(toggle-pause, () { /* 切换暂停状态 */ })特别提醒contextBridge的暴露必须极度谨慎。我们曾经暴露过require结果用户装了个恶意插件通过window.api.require(child_process).exec(calc.exe)直接弹出计算器——这是严重安全漏洞。现在所有暴露 API 都经过白名单校验参数类型强制验证路径操作加根目录限制path.join(app.getPath(userData), data)。3.3 VSCode 插件迁移如何优雅地“杀死”vscode依赖VSCode 插件里满屏的import * as vscode from vscode是迁移最大障碍。直接删掉会报 200 个 TS 错误。我们的渐进式迁移法分三步第一步接口抽象1 天新建src/platform/vscode/vscode-api.ts定义最小接口export interface VsCodeApi { window: { showInformationMessage: (message: string) Thenablevoid showWarningMessage: (message: string) Thenablevoid } workspace: { getConfiguration: (section?: string) any fs: { readFile: (uri: any) ThenableUint8Array } } env: { openExternal: (uri: Uri) Thenableboolean } }然后在插件入口extension.ts里把vscode全局对象包装成这个接口import * as vscode from vscode import { VsCodeApi } from ./platform/vscode/vscode-api export const vscodeApi: VsCodeApi { window: vscode.window, workspace: vscode.workspace, env: vscode.env }第二步依赖注入2 天所有业务文件不再import * as vscode from vscode改为import { VsCodeApi } from /platform/vscode/vscode-api export function useVsCodeService(api: VsCodeApi) { const notify (msg: string) api.window.showInformationMessage(msg) const getConfig (key: string) api.workspace.getConfiguration().get(key) return { notify, getConfig } }第三步平台切换半天Electron 版本里src/platform/electron/electron-api.ts实现相同接口import { Notification } from electron import { Store } from electron-store export class ElectronApi implements VsCodeApi { window { showInformationMessage: (message) { new Notification({ title: 提示, body: message }).show() return Promise.resolve() } } workspace { getConfiguration: () new Store(), fs: { readFile: async (uri: string) { const data await fs.readFile(uri) return new Uint8Array(data) } } } env { openExternal: (uri) { require(electron).shell.openExternal(uri) return Promise.resolve(true) } } }这样业务代码import { VsCodeApi } from /platform/vscode/vscode-api变成import { VsCodeApi } from /platform/apiL4 层决定注入哪个实现。整个过程无任何业务逻辑修改纯架构调整。4. 实操过程与核心环节实现从零构建可发布的 Electron 应用4.1 项目初始化Vite Electron Builder 的黄金组合我们放弃 Electron 官方脚手架electron-forge因为它太重构建慢插件生态乱。Vite Electron Builder 是目前最轻量、最可控的组合。步骤 1初始化 Vite 项目npm create vitelatest typing-game -- --template vue-ts cd typing-game npm install步骤 2安装 Electron 依赖npm install --save-dev electron electron-builder electron-toolkit/utils electron-toolkit/preload npm install electron-is-dev步骤 3配置vite.config.tsimport { defineConfig } from vite import vue from vitejs/plugin-vue import { resolve } from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : resolve(__dirname, src) } }, build: { rollupOptions: { external: [electron] // 告诉 Vite 不要打包 electron 模块 } } })步骤 4创建 Electron 主进程src/main/main.tsimport { app, BrowserWindow, globalShortcut, Notification } from electron import { join } from path import { electronApp, optimizer } from electron-toolkit/utils function createWindow(): void { const mainWindow new BrowserWindow({ width: 1024, height: 768, show: false, webPreferences: { preload: join(__dirname, ../preload/index.js), nodeIntegration: false, contextIsolation: true, sandbox: true } }) mainWindow.loadFile(join(__dirname, ../renderer/index.html)) mainWindow.on(ready-to-show, () { mainWindow.show() }) // 注册全局快捷键 globalShortcut.register(CommandOrControlP, () { mainWindow.webContents.send(toggle-pause) }) } app.whenReady().then(() { createWindow() app.on(activate, () { if (BrowserWindow.getAllWindows().length 0) createWindow() }) }) app.on(window-all-closed, () { if (process.platform ! darwin) app.quit() })步骤 5配置electron-builder.ymlappId: com.typinggame.app productName: TypingGame copyright: Copyright © 2024 artifactName: ${productName}-${version}-${os}-${arch}.${ext} directories: output: dist_electron files: - !node_modules/**/* - !src/**/* - !tests/**/* - !electron-builder.yml - !package-lock.json - build/**/* - dist/**/* - node_modules/**/* - package.json - main.js - preload.js win: target: - target: nsis icon: build/icon.ico mac: target: - target: dmg icon: build/icon.icns linux: target: - target: deb icon: build/icon.png实操心得files字段必须精确控制打包内容漏掉main.js会导致启动黑屏多打包node_modules会让安装包从 30MB 涨到 120MB。我们用electron-builder的--publish never参数本地测试确认dist_electron/win-unpacked/目录下只有 12 个文件不含 node_modules才算合格。4.2 预加载脚本preload.ts安全与能力的平衡点preload.ts是渲染进程和主进程之间的唯一可信通道它必须做到最小权限、最大能力、绝对安全。import { contextBridge, ipcRenderer } from electron // 1. 只暴露必要 API const api { // 通知 notify: (title: string, body: string) { ipcRenderer.send(notify, { title, body }) }, // 配置读写 getConfig: (key: string) ipcRenderer.invoke(get-config, key), setConfig: (key: string, value: any) ipcRenderer.invoke(set-config, key, value), // 文件读取仅限 userData 目录 readFile: (filename: string) { // 白名单校验 if (!/^[a-zA-Z0-9_-]\.json$/.test(filename)) { throw new Error(Invalid filename) } return ipcRenderer.invoke(read-file, filename) } } // 2. 用 contextBridge 安全暴露 contextBridge.exposeInMainWorld(electronAPI, api) // 3. 拦截危险操作 window.addEventListener(DOMContentLoaded, () { // 禁止 eval const originalEval window.eval window.eval () { throw new Error(eval is disabled) } // 禁止动态 script 标签 const originalCreateElement document.createElement document.createElement (tagName) { if (tagName.toLowerCase() script) { throw new Error(Dynamic script tag is disabled) } return originalCreateElement.call(document, tagName) } })关键点说明contextBridge.exposeInMainWorld是唯一安全的暴露方式window.electronAPI在渲染进程可用但无法访问 Node.js 全局对象。所有 IPC 调用都经过invoke有返回值或send无返回值避免sendSync同步阻塞已废弃。白名单校验filename是防止路径遍历攻击../../../etc/passwd。eval和script标签拦截是防止 XSS 攻击即使你的 Vue 组件用了v-html也无法执行 JS。4.3 打字核心引擎实现Vue 3 Composition API 实战src/composables/useTypingEngine.ts是整个游戏的灵魂它用 Composition API 实现了高精度输入状态管理import { ref, reactive, computed, onMounted, onUnmounted } from vue import { TypingStats, WordStream } from /core/domain export function useTypingEngine() { // L1 核心领域对象 const engine new TypingEngine() const wordStream new WordStream() // 响应式状态 const state reactive({ currentWord: , userInput: , isPaused: false, isCompleted: false, errorCount: 0, lastErrorTime: 0 }) // 计算属性 const stats computedTypingStats(() engine.getStats()) const progress computed(() { const total wordStream.getTotalWords() const current wordStream.getCurrentIndex() return Math.round((current / total) * 100) }) // 输入处理 const handleInput (value: string) { if (state.isPaused || state.isCompleted) return // 防连击100ms 内重复输入忽略 const now Date.now() if (now - state.lastErrorTime 100) return // 核心逻辑交由 L1 处理 const result engine.processInput(value, state.currentWord) state.userInput result.input state.errorCount result.errors // 错误锁定连续 3 次错误锁定 2 秒 if (result.errors 3 now - state.lastErrorTime 2000) { state.isPaused true setTimeout(() { state.isPaused false }, 2000) state.lastErrorTime now } } // 生命周期 onMounted(() { // 初始化单词流 wordStream.init() state.currentWord wordStream.next() }) onUnmounted(() { engine.reset() }) return { state, stats, progress, handleInput, nextWord: () { state.currentWord wordStream.next() state.userInput state.errorCount 0 } } }为什么这个设计能扛住高频输入engine.processInput()是纯计算函数无副作用可被 Vue 的响应式系统高效追踪。handleInput里state.lastErrorTime时间戳校验比setTimeout更精准避免闭包导致的内存泄漏。onUnmounted显式重置引擎防止组件销毁后状态残留。所有 DOM 操作如聚焦输入框都在组件input的input事件里完成useTypingEngine只管业务逻辑不碰 DOM。4.4 菜单与系统集成让应用像原生一样呼吸Electron 默认菜单是简陋的“Electron”菜单必须重写。我们按平台定制// src/main/menu.ts import { app, Menu, MenuItemConstructorOptions, shell } from electron import { join } from path const isMac process.platform darwin const template: MenuItemConstructorOptions[] [ // macOS 应用菜单 ...(isMac ? [{ label: app.name, submenu: [ { role: about }, { type: separator }, { role: services }, { type: separator }, { role: hide }, { role: hideothers }, { role: unhide }, { type: separator }, { role: quit } ] }] : []), // 文件菜单 { label: 文件, submenu: [ { role: quit } ] }, // 编辑菜单 { label: 编辑, submenu: [ { role: undo }, { role: redo }, { type: separator }, { role: cut }, { role: copy }, { role: paste }, ...(isMac ? [{ role: pasteAndMatchStyle }, { role: delete }, { role: selectAll }] : [{ role: delete }, { role: selectAll }]) ] }, // 视图菜单 { label: 视图, submenu: [ { role: reload }, { role: forceReload }, { role: toggleDevTools }, { type: separator }, { role: resetZoom }, { role: zoomIn }, { role: zoomOut }, { type: separator }, { role: togglefullscreen } ] }, // 窗口菜单 { label: 窗口, submenu: [ { role: minimize }, { role: zoom }, ...(isMac ? [{ type: separator }, { role: front }] : [{ role: close }]) ] } ] // 自定义菜单项 if (!isMac) { template.push({ label: 帮助, submenu: [ { label: 访问官网, click: () shell.openExternal(https://typinggame.dev) }, { label: 查看 GitHub, click: () shell.openExternal(https://github.com/typinggame/app) } ] }) } const menu Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu)关键细节isMac分支处理 macOS 特有菜单项如about、services避免 Windows 上出现灰色不可用菜单。shell.openExternal()代替window.open()确保外链在系统默认浏览器打开而非新 Electron 窗口。role: toggleDevTools保留开发者工具但生产环境会自动禁用mainWindow.webContents.openDevTools({ mode: detach })在开发时启用。5. 常见问题与排查技巧实录血泪教训总结5.1 构建失败Cannot find module electron的 3 种真相这个问题出现频率最高但原因各不相同现象真实原因解决方案npm run build报错Cannot find module electronelectron被安装在devDependencies但electron-builder构建时需要dependenciesnpm install electron --save注意是--save不是--save-devdist_electron/win-unpacked/目录下没有electron.exeelectron-builder配置files字段漏掉了node_modules/electron/dist/删除files字段让 builder 自动扫描或显式添加- node_modules/electron/dist/**/*打包后 exe 双击闪退日志显示Cannot find module electronpackage.json的main字段指向src/main/main.ts但 builder 只打包dist_electron下的 JS未编译 TSpackage.json中main改为dist_electron/main.js并在build脚本里加tsc -p tsconfig.main.json编译主进程实操心得我们用electron-builder的--debug参数启动构建会生成详细日志关键看Packaging for platform win32 x64 using electron 28.2.0这行是否出现没出现说明 Electron 二进制没下载成功手动运行npx electron --version测试。5.2 输入延迟Chrome 渲染线程被阻塞的定位方法用户反馈“打字卡顿”但 CPU 占用只有 5%这不是性能问题是渲染线程被阻塞。定位步骤打开 DevTools → Performance → Start profiling疯狂输入 10 秒Stop recording看 Flame Chart找长条状的Recalculate Style或Layout右键长条 → “View Call Stack”看是哪个 CSS 触发了重排我们遇到的真实案例.word-container { transform: translateX(0); }这个 CSS 触发了强制同步布局因为translateX(0)被浏览器当作“需要重绘”而我们的单词容器每秒更新 60 次。解决方案是改用will-change: transform提前告知浏览器.word-container { will-change: transform; transform: translateX(0); }另一个案例Vue 的v-for渲染 100 个单词每次输入都触发全量重渲染。解决方案是用key精确控制更新div v-for(word, index) in words :key${word.text}-${index}-${word.state} !-- word.state 包含 active/correct/error状态变才重渲染 -- /div5.3 Windows 杀毒软件误报如何让安装包“看起来更干净”Electron 应用被 360、腾讯电脑管家标为“风险程序”不是因为病毒是因为它打包了 Node.js 运行时行为像木马。解决方法措施效果操作代码签名最有效90% 误报消失购买 DigiCert 或 Sectigo 代码签名证书electron-builder配置win.certificateSubjectNameUPX 压缩减小体积降低启发式扫描命中率npm install upx-loader --save-dev在electron-builder.yml加compression: maximum禁用 Node.js 集成移除nodeIntegration: true用contextIsolation: truepreload我们的preload.ts已强制启用这是基础要求删除调试符号减少可疑字符串electron-builder配置extraResources不打包.pdb文件我们最终采用代码签名 UPX 压缩误报率从 82% 降到 3%。注意免费证书如 Lets Encrypt不支持代码签名必须商业证书。5.4 macOS 上托盘图标不显示NSStatusBar 的隐藏陷阱macOS 上Tray图标不显示常见于 M1/M2 芯片 Mac。原因Apple Silicon 要求图标必须是.png格式且尺寸严格为 18x18px1x、36x36px2x不能是 SVG 或其他尺寸。解决方案准备两套图标tray-icon.png18x18tray-icon2x.png36x36在main.ts中import { app, Tray, Menu, nativeImage } from electron let tray: Tray | null null if (app.isReady()) { const iconPath join(__dirname, ../build/tr
返回列表