fix(electron): 为 IPC 监听器添加取消订阅函数返回值
使 `electronApi.on` 方法返回一个清理函数,允许调用方主动取消订阅。 在 Vue 组件中收集这些清理函数,并在组件卸载时统一执行,确保无内存泄漏。 同时更新了 TypeScript 类型定义以反映此变更。
This commit is contained in:
1 parent
67504e81d0
commit
78d6271882
6 files changed
+93
-23
No files matched your search
@@ -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 核心自动管理,支持代理订阅自动更新与节点订阅解析。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` 后缀),确保规则匹配的准确性。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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) { }
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user