fix(electron): 为 IPC 监听器添加取消订阅函数返回值

使 `electronApi.on` 方法返回一个清理函数,允许调用方主动取消订阅。
在 Vue 组件中收集这些清理函数,并在组件卸载时统一执行,确保无内存泄漏。
同时更新了 TypeScript 类型定义以反映此变更。
This commit is contained in:
ZenEcho committed 2026-03-03 11:34:42 +08:00
1 parent 67504e81d0
commit 78d6271882
6 files changed
+93 -23

No files matched your search

+2 -3
View File
@@ -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 核心自动管理,支持代理订阅自动更新与节点订阅解析。
---
+13 -4
View File
@@ -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` 后缀),确保规则匹配的准确性。
+7
View File
@@ -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)
}
}
},
/**
+54 -5
View File
@@ -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
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ElectronAPI {
pickProcess: () => Promise<string[] | null>
pickProcessFolder: (maxDepth?: number) => Promise<string[] | null>
openDevTools: () => Promise<void>
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<any>
+16 -10
View File
@@ -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) { }
})