From 78d6271882692e01fd85aa999f2a704e0fadf6f9 Mon Sep 17 00:00:00 2001 From: ZenEcho Date: Tue, 3 Mar 2026 11:34:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(electron):=20=E4=B8=BA=20IPC=20=E7=9B=91?= =?UTF-8?q?=E5=90=AC=E5=99=A8=E6=B7=BB=E5=8A=A0=E5=8F=96=E6=B6=88=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E5=87=BD=E6=95=B0=E8=BF=94=E5=9B=9E=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使 `electronApi.on` 方法返回一个清理函数,允许调用方主动取消订阅。 在 Vue 组件中收集这些清理函数,并在组件卸载时统一执行,确保无内存泄漏。 同时更新了 TypeScript 类型定义以反映此变更。 --- README.md | 5 ++- docs/electron-structure.md | 17 +++++++--- electron/preload/index.ts | 7 +++++ src/layouts/MainLayout.vue | 59 ++++++++++++++++++++++++++++++++--- src/types/electron.d.ts | 2 +- src/views/dashboard/index.vue | 26 +++++++++------ 6 files changed, 93 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4ebef8b..f5266ab 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,9 @@ - 🎨 **现代美学**: 深度定制的 **Vue 3** + **Naive UI** 设计风格,提供极致的暗色模式与自定义主题色体验。 - 🎮 **智能游戏库**: 自动扫描并识别本地游戏(Steam、Xbox 等),一键配置加速。 - 🛠️ **精细化控制**: 独创的 **Process Proxy** 进程追踪技术,搭配 TUN 虚拟网卡模式,实现进程级路由分流。 -- 🌐 **多协议支持**: 全面支持 Shadowsocks, VMess, VLESS, Trojan, TUIC, Hysteria2 等主流与新兴代理协议。 -- 🔌 **本地代理系统**: 内置本地 HTTP/SOCKS 代理服务,支持 PAC 自动代理配置脚本。 +- 🌐 **多协议支持**: 全面支持 Shadowsocks, VMess, VLESS, Trojan 等主流与新兴代理协议。 +- 🔌 **本地代理系统**: 内置本地 HTTP/SOCKS 代理服务。 - 📊 **流量可视化**: 实时网络延迟测试、节点状态监测与流量统计图表(基于 ECharts)。 -- 📦 **开箱即用**: 内置 sing-box 核心自动管理,支持代理订阅自动更新与节点订阅解析。 --- diff --git a/docs/electron-structure.md b/docs/electron-structure.md index 19e3324..db9e5ea 100644 --- a/docs/electron-structure.md +++ b/docs/electron-structure.md @@ -39,7 +39,7 @@ electron/ │ ├── database.ts # 数据库服务:封装 SQLite 连接与基础 CRUD 操作 │ ├── game-scanner.ts # 游戏扫描服务:统一入口,聚合 scanners 目录下的扫描逻辑 │ ├── game.ts # 游戏管理服务:管理“我的游戏”列表、配置与状态 -│ ├── node.ts # 节点管理服务:管理代理节点/订阅信息 +│ ├── node.ts # 节点管理服务:管理代理节点 │ ├── process.ts # 进程管理服务:提供进程树查找、父子进程分析能力 │ ├── proxy-monitor.ts # 监控服务:实时监控游戏进程启动,自动触发加速路由 │ ├── system.ts # 系统服务:提供 DNS 刷新、虚拟网卡重置、网络测试等底层能力 @@ -57,32 +57,41 @@ electron/ ## 模块详细说明 ### 1. 入口层 (`main/`) + 经过重构,入口层职责更加单一: + - **`index.ts`**: 核心入口,负责组装各个模块,初始化服务。 - **`bootstrap.ts`**: 处理应用启动前的环境准备,如强制管理员权限、处理 SQLite 原生模块错误。 - **`window.ts`**: 封装了 `BrowserWindow` 的创建与配置,管理窗口 IPC 事件(最小化/关闭)。 - **`tray.ts`**: 独立管理系统托盘逻辑。 ### 2. 业务层 (`services/`) + 这是代码最集中的地方,每个文件对应一个具体的业务领域。 #### 核心服务:`singbox/` + 将原有的 `singbox.ts` 拆分为独立模块: + - **`index.ts`**: 服务外观 (Facade),对外提供 `start`, `stop`, `restart` 接口。 - **`installer.ts`**: 专注于二进制文件的下载、解压和安装逻辑。 - **`config.ts`**: 专注于配置文件的生成、校验和动态规则更新。 #### 扫描服务:`game-scanner.ts` & `scanners/` + - **`game-scanner.ts`**: 作为统一入口,对外暴露 `scanLocalGamesFromPlatforms`。 - **`scanners/`**: 包含各平台的具体扫描实现: - - **`steam.ts`**: 解析 Steam 库文件。 - - **`microsoft.ts`**: 结合注册表和 AppxManifest 解析 Xbox 游戏。 - - **`flat.ts`**: 简单的目录遍历扫描 (Epic/EA)。 + - **`steam.ts`**: 解析 Steam 库文件。 + - **`microsoft.ts`**: 结合注册表和 AppxManifest 解析 Xbox 游戏。 + - **`flat.ts`**: 简单的目录遍历扫描 (Epic/EA)。 #### 监控服务:`proxy-monitor.ts` + - 实现了智能进程捕获。它会轮询系统进程树,当发现已配置的游戏进程及其子进程启动时,自动通知 `singbox` 服务添加分流规则。 ### 3. 工具层 (`utils/`) + 提炼了纯函数工具,不依赖具体业务逻辑,易于测试和复用。 + - **`command.ts`**: 解决了 Node.js 原生 `exec` 在 Windows 下的一些编码和窗口隐藏问题。 - **`process-helper.ts`**: 统一了全项目的进程名处理逻辑(如忽略大小写、去除 `.exe` 后缀),确保规则匹配的准确性。 diff --git a/electron/preload/index.ts b/electron/preload/index.ts index d5f9e9c..36c68f9 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -48,6 +48,13 @@ contextBridge.exposeInMainWorld('electron', { channelMap.set(callback, wrapped) ipcListenerMap.set(channel, channelMap) ipcRenderer.on(channel, wrapped) + return () => { + ipcRenderer.removeListener(channel, wrapped) + const mapped = ipcListenerMap.get(channel) + if (mapped?.get(callback) === wrapped) { + mapped.delete(callback) + } + } }, /** diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 2e8e07f..5e724f0 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -157,6 +157,8 @@ import { useGameStore } from '@/stores/games' import { useNodeStore } from '@/stores/nodes' import { useSettingsStore } from '@/stores/settings' import { storeToRefs } from 'pinia' +import { useI18n } from 'vue-i18n' +import { useMessage } from 'naive-ui' const { width } = useWindowSize() const isCollapsed = ref(true) @@ -166,11 +168,14 @@ const gameStore = useGameStore() const nodeStore = useNodeStore() const settingsStore = useSettingsStore() const { checkInterval } = storeToRefs(settingsStore) +const { t } = useI18n() +const message = useMessage() const trayCoreInstalled = ref(true) const trayDurationTick = ref(Date.now()) const traySessionLoss = ref(0) const showCloseConfirmModal = ref(false) const closeRememberChoice = ref(false) +const ipcCleanupFns: Array<() => void> = [] const activeGame = computed(() => gameStore.getAcceleratingGame()) const trayDisplayGame = computed(() => activeGame.value || gameStore.currentGame) @@ -227,7 +232,8 @@ onMounted(async () => { resumeGlobalSampling() try { if (typeof electronApi.on === 'function') { - electronApi.on('singbox-installer-status', onInstallerStatusChange) + const onInstallerStatusChangeDisposer = electronApi.on('singbox-installer-status', onInstallerStatusChange) + if (typeof onInstallerStatusChangeDisposer === 'function') ipcCleanupFns.push(onInstallerStatusChangeDisposer) } singboxApi.getInstallInfo().then((info) => { trayCoreInstalled.value = !!info?.exists @@ -329,6 +335,42 @@ function onCloseConfirmRequired() { showCloseConfirmModal.value = true } +async function onTrayDoToggle() { + if (gameStore.operationState !== 'idle') return + + const running = gameStore.getAcceleratingGame() + if (running?.id) { + try { + await gameStore.stopGame(running.id) + } catch (e: any) { + message.error(String(e?.message || e || t('games.stop_failed'))) + } + return + } + + const current = gameStore.currentGame + if (!current?.id) { + message.warning(String(t('games.no_game_selected'))) + return + } + if (!current.nodeId) { + message.warning(String(t('dashboard.please_select_node'))) + return + } + + try { + await gameStore.startGame(current.id) + } catch (e: any) { + const rawMsg = String(e?.message || e || '') + if (rawMsg.startsWith('Another game is already accelerating:')) { + const gameName = rawMsg.replace('Another game is already accelerating:', '').trim() + message.warning(gameName ? `已有游戏正在加速:${gameName},请先停止后再切换。` : '已有游戏正在加速,请先停止后再切换。') + return + } + message.error(rawMsg || String(t('dashboard.start_failed'))) + } +} + async function submitCloseDecision(action: 'minimize' | 'quit' | 'cancel') { try { await electronApi.submitWindowCloseDecision({ @@ -362,9 +404,13 @@ const close = () => electronApi.close() onUnmounted(() => { pauseTrayDurationTicker() try { - if (typeof electronApi.off === 'function') { - electronApi.off('singbox-installer-status', onInstallerStatusChange) - electronApi.off('window:close-confirm-required', onCloseConfirmRequired) + while (ipcCleanupFns.length > 0) { + const dispose = ipcCleanupFns.pop() + try { + dispose?.() + } catch { + // ignore + } } } catch { // ignore @@ -374,7 +420,10 @@ onUnmounted(() => { onMounted(() => { try { if (typeof electronApi.on === 'function') { - electronApi.on('window:close-confirm-required', onCloseConfirmRequired) + const onCloseConfirmRequiredDisposer = electronApi.on('window:close-confirm-required', onCloseConfirmRequired) + if (typeof onCloseConfirmRequiredDisposer === 'function') ipcCleanupFns.push(onCloseConfirmRequiredDisposer) + const onTrayDoToggleDisposer = electronApi.on('tray:do-toggle', onTrayDoToggle) + if (typeof onTrayDoToggleDisposer === 'function') ipcCleanupFns.push(onTrayDoToggleDisposer) } } catch { // ignore diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index c988423..9b1f268 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -11,7 +11,7 @@ export interface ElectronAPI { pickProcess: () => Promise pickProcessFolder: (maxDepth?: number) => Promise openDevTools: () => Promise - on: (channel: string, callback: (...args: any[]) => void) => void + on: (channel: string, callback: (...args: any[]) => void) => (() => void) | void off: (channel: string, callback: (...args: any[]) => void) => void traySyncState: (state: any) => void trayGetState: () => Promise diff --git a/src/views/dashboard/index.vue b/src/views/dashboard/index.vue index 3bd2d58..be30bcb 100644 --- a/src/views/dashboard/index.vue +++ b/src/views/dashboard/index.vue @@ -378,6 +378,7 @@ const lostSamples = ref(0) const showSessionTuningPanel = ref(false) const highLossHintShown = ref(false) const applyingSessionTuning = ref(false) +const ipcCleanupFns: Array<() => void> = [] const udpModeOptions = computed(() => ([ { label: String(t('settings.udp_mode_auto')), value: 'auto' }, @@ -683,11 +684,14 @@ onMounted(() => { resumeDuration() } try { - electronApi.on('singbox-log', (d: any) => onSingboxEvent('singbox-log', d)) - electronApi.on('singbox-error', (d: any) => onSingboxEvent('singbox-error', d)) - electronApi.on('singbox-status', (d: any) => onSingboxEvent('singbox-status', d)) - electronApi.on('singbox-installer-status', onInstallerStatusChange) - electronApi.on('tray:do-toggle', toggleAccelerator) + const onLogDisposer = electronApi.on('singbox-log', (d: any) => onSingboxEvent('singbox-log', d)) + if (typeof onLogDisposer === 'function') ipcCleanupFns.push(onLogDisposer) + const onErrorDisposer = electronApi.on('singbox-error', (d: any) => onSingboxEvent('singbox-error', d)) + if (typeof onErrorDisposer === 'function') ipcCleanupFns.push(onErrorDisposer) + const onStatusDisposer = electronApi.on('singbox-status', (d: any) => onSingboxEvent('singbox-status', d)) + if (typeof onStatusDisposer === 'function') ipcCleanupFns.push(onStatusDisposer) + const onInstallerDisposer = electronApi.on('singbox-installer-status', onInstallerStatusChange) + if (typeof onInstallerDisposer === 'function') ipcCleanupFns.push(onInstallerDisposer) singboxApi.getInstallInfo().then((info) => { isCoreInstalled.value = info.exists }).catch(() => { @@ -741,11 +745,13 @@ onUnmounted(() => { pauseSampling() pauseDuration() try { - // Logic for cleanup if necessary - // electronApi.off is defined as function in types, so no need to check existence if we trust types. - // However, if we want to be safe at runtime: - if (typeof electronApi.off === 'function') { - electronApi.off('tray:do-toggle', toggleAccelerator) + while (ipcCleanupFns.length > 0) { + const dispose = ipcCleanupFns.pop() + try { + dispose?.() + } catch { + // ignore + } } } catch (e) { } })