From da80dbc761484d4e44f8cb4a6ba6dbbfe3234ef8 Mon Sep 17 00:00:00 2001 From: alger Date: Sun, 5 Jul 2026 14:06:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(main):=20=E4=BF=AE=E5=A4=8D=20config.json?= =?UTF-8?q?=20=E5=B9=B6=E5=8F=91=E8=AF=BB=E5=86=99=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=20EBUSY=20=E4=B8=BB=E8=BF=9B=E7=A8=8B=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主进程 6 个独立 Store 实例合并为共享单例(window-size/lyric/server/deviceInfo/window 复用 config.ts 实例) - initializeConfig 幂等化,修复 set-store-value IPC 监听重复注册导致的双倍写入 - 窗口/歌词窗 move、resize 保存增加 500ms 防抖,拖动时不再每秒几十次写盘,close 时冲刷落盘 - store 读写 IPC handler 与窗口状态读写加 try-catch,撞锁丢弃单次写入而非崩溃 - 全局 uncaughtException 兜底:文件锁类错误(EBUSY/EPERM 等)仅记日志,其余异常保留报错弹窗 Closes #714 --- src/main/index.ts | 21 ++++++++- src/main/lyric.ts | 30 ++++++++++--- src/main/modules/config.ts | 78 +++++++++++++++++++++++---------- src/main/modules/deviceInfo.ts | 5 ++- src/main/modules/window-size.ts | 61 +++++++++++++++++++++----- src/main/modules/window.ts | 4 +- src/main/server.ts | 4 +- 7 files changed, 154 insertions(+), 49 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 150ebc6..99d20e4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,26 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'; -import { app, ipcMain, nativeImage, protocol, session } from 'electron'; +import { app, dialog, ipcMain, nativeImage, protocol, session } from 'electron'; import { join } from 'path'; +// 全局兜底(#714):Windows 上 config.json 等文件可能被杀毒/云同步软件短暂锁定, +// electron-store 读写撞锁会抛 EBUSY 等未捕获异常,Electron 默认弹出致命错误框。 +// 对带 path 的文件系统锁类错误仅记录日志;其余异常保留报错弹窗以免掩盖真 bug。 +const FILE_LOCK_ERROR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'EAGAIN', 'EMFILE', 'ENFILE']); +process.on('uncaughtException', (error: NodeJS.ErrnoException) => { + if (error?.code && FILE_LOCK_ERROR_CODES.has(error.code) && typeof error.path === 'string') { + console.error('[main] 文件被占用/锁定,已忽略本次读写:', error.message); + return; + } + console.error('[main] 未捕获异常:', error); + dialog.showErrorBox( + 'A JavaScript error occurred in the main process', + error?.stack || String(error) + ); +}); +process.on('unhandledRejection', (reason) => { + console.error('[main] 未处理的 Promise 拒绝:', reason); +}); + // 必须在 app.whenReady() 之前注册自定义协议为特权协议, // 否则 http(s) 页面(dev server、生产环境的 file://)无法把 local:// 当成 // 安全/可 fetch/可流式的资源加载,会触发 CORS 拦截或 net::ERR_UNKNOWN_URL_SCHEME diff --git a/src/main/lyric.ts b/src/main/lyric.ts index 46b31e9..40846d5 100644 --- a/src/main/lyric.ts +++ b/src/main/lyric.ts @@ -1,10 +1,28 @@ import { BrowserWindow, IpcMain, screen } from 'electron'; -import Store from 'electron-store'; import path, { join } from 'path'; -const store = new Store(); +import { getSharedStore } from './modules/config'; + +const store = getSharedStore(); let lyricWindow: BrowserWindow | null = null; +// 歌词窗口 bounds 防抖保存:拖动/缩放时高频触发, +// 直接写盘会加剧 config.json 文件争用(#714 EBUSY) +let lyricBoundsSaveTimer: ReturnType | null = null; +const saveLyricWindowBounds = (bounds: Record) => { + if (lyricBoundsSaveTimer) { + clearTimeout(lyricBoundsSaveTimer); + } + lyricBoundsSaveTimer = setTimeout(() => { + lyricBoundsSaveTimer = null; + try { + store.set('lyricWindowBounds', bounds); + } catch (error) { + console.error('保存歌词窗口位置失败:', error); + } + }, 500); +}; + // 跟踪拖动状态 let isDragging = false; @@ -196,8 +214,8 @@ const createWin = () => { const [width, height] = lyricWindow.getSize(); const [x, y] = lyricWindow.getPosition(); - // 保存窗口位置和大小 - store.set('lyricWindowBounds', { x, y, width, height }); + // 保存窗口位置和大小(防抖) + saveLyricWindowBounds({ x, y, width, height }); } }); @@ -357,7 +375,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void false ); - // 更新存储的位置 + // 更新存储的位置(防抖,拖动结束后统一落盘) const windowBounds = { x: newX, y: newY, @@ -365,7 +383,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void height: windowHeight, displayId: currentDisplay.id // 记录当前显示器ID,有助于多屏幕处理 }; - store.set('lyricWindowBounds', windowBounds); + saveLyricWindowBounds(windowBounds); } catch (error) { console.error('Error during window drag:', error); // 出错时尝试使用更简单的方法 diff --git a/src/main/modules/config.ts b/src/main/modules/config.ts index b59f04f..1ded041 100644 --- a/src/main/modules/config.ts +++ b/src/main/modules/config.ts @@ -38,41 +38,63 @@ interface StoreType { shortcuts: ShortcutsConfig; } -let store: Store; +// 模块级单例:主进程所有模块共享同一个 config.json Store 实例。 +// 多个独立 Store 实例并发读写同一文件,会在 Windows 上与杀毒/云同步的文件锁 +// 叠加触发 EBUSY 未捕获异常(#714) +const store = new Store({ + name: 'config', + defaults: { + set: set as SetConfig, + shortcuts: createDefaultShortcuts() + } +}); + +let initialized = false; /** - * 初始化配置管理 + * 初始化配置管理(幂等:重复调用不会重复注册 IPC 监听) */ export function initializeConfig() { - store = new Store({ - name: 'config', - defaults: { - set: set as SetConfig, - shortcuts: createDefaultShortcuts() - } - }); + if (initialized) { + return store; + } + initialized = true; - store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads')); - store.get('set.diskCacheDir') || - store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache')); - if (store.get('set.diskCacheMaxSizeMB') === undefined) { - store.set('set.diskCacheMaxSizeMB', 4096); - } - if (!store.get('set.diskCacheCleanupPolicy')) { - store.set('set.diskCacheCleanupPolicy', 'lru'); - } - if (store.get('set.enableDiskCache') === undefined) { - store.set('set.enableDiskCache', true); + try { + store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads')); + store.get('set.diskCacheDir') || + store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache')); + if (store.get('set.diskCacheMaxSizeMB') === undefined) { + store.set('set.diskCacheMaxSizeMB', 4096); + } + if (!store.get('set.diskCacheCleanupPolicy')) { + store.set('set.diskCacheCleanupPolicy', 'lru'); + } + if (store.get('set.enableDiskCache') === undefined) { + store.set('set.enableDiskCache', true); + } + } catch (error) { + console.error('[config] 初始化默认配置失败:', error); } // 定义ipcRenderer监听事件 ipcMain.on('set-store-value', (_, key, value) => { - store.set(key, value); + try { + store.set(key, value); + } catch (error) { + // config.json 可能被杀毒/云同步短暂锁定,丢一次写入无害,避免主进程崩溃 + console.error(`[config] 写入配置失败 key=${key}:`, error); + } }); - ipcMain.on('get-store-value', (_, key) => { - const value = store.get(key); - _.returnValue = value || ''; + ipcMain.on('get-store-value', (event, key) => { + try { + const value = store.get(key); + event.returnValue = value || ''; + } catch (error) { + console.error(`[config] 读取配置失败 key=${key}:`, error); + event.returnValue = ''; + } }); // GPU加速设置更新处理 @@ -98,3 +120,11 @@ export function initializeConfig() { export function getStore() { return store; } + +/** + * 供主进程其他模块共享同一个 config.json Store 实例 + * (不要再 new Store(),多实例并发读写是 #714 EBUSY 崩溃的诱因之一) + */ +export function getSharedStore(): Store> { + return store as unknown as Store>; +} diff --git a/src/main/modules/deviceInfo.ts b/src/main/modules/deviceInfo.ts index b9f0ed7..3731088 100644 --- a/src/main/modules/deviceInfo.ts +++ b/src/main/modules/deviceInfo.ts @@ -1,9 +1,10 @@ import { app } from 'electron'; -import Store from 'electron-store'; import { machineIdSync } from 'node-machine-id'; import os from 'os'; -const store = new Store(); +import { getSharedStore } from './config'; + +const store = getSharedStore(); /** * 获取设备唯一标识符 diff --git a/src/main/modules/window-size.ts b/src/main/modules/window-size.ts index addb951..560570b 100644 --- a/src/main/modules/window-size.ts +++ b/src/main/modules/window-size.ts @@ -1,7 +1,9 @@ import { app, BrowserWindow, ipcMain, screen } from 'electron'; -import Store from 'electron-store'; +import type Store from 'electron-store'; -const store = new Store(); +import { getSharedStore } from './config'; + +const store = getSharedStore(); // 默认窗口尺寸 export const DEFAULT_MAIN_WIDTH = 1200; @@ -38,16 +40,40 @@ export interface WindowState { * 负责保存、恢复和维护窗口大小状态 */ class WindowSizeManager { - private store: Store; + private store: Store>; private mainWindow: BrowserWindow | null = null; private savedState: WindowState | null = null; private isInitialized: boolean = false; + private saveStateDebounceTimer: ReturnType | null = null; constructor() { this.store = store; // 初始化时不做与screen相关的操作,等app ready后再初始化 } + /** + * 防抖保存窗口状态:move/resize 在拖动时每秒触发几十次, + * 直接写 config.json 是 #714 EBUSY 争用的最大来源 + */ + private scheduleSaveWindowState(win: BrowserWindow): void { + if (this.saveStateDebounceTimer) { + clearTimeout(this.saveStateDebounceTimer); + } + this.saveStateDebounceTimer = setTimeout(() => { + this.saveStateDebounceTimer = null; + if (!win.isDestroyed() && !win.isMinimized()) { + this.saveWindowState(win); + } + }, 500); + } + + private flushScheduledSave(): void { + if (this.saveStateDebounceTimer) { + clearTimeout(this.saveStateDebounceTimer); + this.saveStateDebounceTimer = null; + } + } + /** * 初始化窗口大小管理器 * 必须在app ready后调用 @@ -117,17 +143,17 @@ class WindowSizeManager { * 设置事件监听器 */ private setupEventListeners(win: BrowserWindow): void { - // 监听窗口大小调整事件 + // 监听窗口大小调整事件(防抖,拖动结束后统一落盘) win.on('resize', () => { if (!win.isDestroyed() && !win.isMinimized()) { - this.saveWindowState(win); + this.scheduleSaveWindowState(win); } }); - // 监听窗口移动事件 + // 监听窗口移动事件(防抖,拖动结束后统一落盘) win.on('move', () => { if (!win.isDestroyed() && !win.isMinimized()) { - this.saveWindowState(win); + this.scheduleSaveWindowState(win); } }); @@ -145,8 +171,9 @@ class WindowSizeManager { } }); - // 监听窗口关闭事件,确保保存最终状态 + // 监听窗口关闭事件,确保保存最终状态(取消挂起的防抖,立即落盘) win.on('close', () => { + this.flushScheduledSave(); if (!win.isDestroyed()) { this.saveWindowState(win); } @@ -409,9 +436,13 @@ class WindowSizeManager { return state; } - // 保存状态到存储 - this.store.set(WINDOW_STATE_KEY, state); - console.log(`已保存窗口状态: ${JSON.stringify(state)}`); + // 保存状态到存储(config.json 可能被外部程序短暂锁定,失败时丢弃本次写入即可) + try { + this.store.set(WINDOW_STATE_KEY, state); + console.log(`已保存窗口状态: ${JSON.stringify(state)}`); + } catch (error) { + console.error('保存窗口状态失败:', error); + } // 更新内部状态 this.savedState = state; @@ -424,7 +455,13 @@ class WindowSizeManager { * 获取保存的窗口状态 */ getWindowState(): WindowState | null { - const state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined; + let state: WindowState | undefined; + try { + state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined; + } catch (error) { + console.error('读取窗口状态失败:', error); + return this.savedState; + } if (!state) { console.log('未找到保存的窗口状态,将使用默认值'); diff --git a/src/main/modules/window.ts b/src/main/modules/window.ts index 048f6fc..8107bc2 100644 --- a/src/main/modules/window.ts +++ b/src/main/modules/window.ts @@ -9,9 +9,9 @@ import { session, shell } from 'electron'; -import Store from 'electron-store'; import { join } from 'path'; +import { getSharedStore } from './config'; import { applyContentZoom, applyInitialState, @@ -27,7 +27,7 @@ import { WindowState } from './window-size'; -const store = new Store(); +const store = getSharedStore(); // 保存主窗口引用,以便在 activate 事件中使用 let mainWindowInstance: BrowserWindow | null = null; diff --git a/src/main/server.ts b/src/main/server.ts index 4594ee9..ce70cba 100644 --- a/src/main/server.ts +++ b/src/main/server.ts @@ -1,9 +1,9 @@ import { ipcMain } from 'electron'; -import Store from 'electron-store'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import { getSharedStore } from './modules/config'; import { type Platform, unblockMusic } from './unblockMusic'; // 必须在 import netease-cloud-music-api-alger 之前创建 anonymous_token 文件 @@ -12,7 +12,7 @@ if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) { fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8'); } -const store = new Store(); +const store = getSharedStore(); // 设置音乐解析的处理程序 ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => {