mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-07-08 02:17:30 +08:00
fix(main): 修复 config.json 并发读写导致的 EBUSY 主进程崩溃弹窗
- 主进程 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
This commit is contained in:
+20
-1
@@ -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
|
||||
|
||||
+24
-6
@@ -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<typeof setTimeout> | null = null;
|
||||
const saveLyricWindowBounds = (bounds: Record<string, number>) => {
|
||||
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);
|
||||
// 出错时尝试使用更简单的方法
|
||||
|
||||
+54
-24
@@ -38,41 +38,63 @@ interface StoreType {
|
||||
shortcuts: ShortcutsConfig;
|
||||
}
|
||||
|
||||
let store: Store<StoreType>;
|
||||
// 模块级单例:主进程所有模块共享同一个 config.json Store 实例。
|
||||
// 多个独立 Store 实例并发读写同一文件,会在 Windows 上与杀毒/云同步的文件锁
|
||||
// 叠加触发 EBUSY 未捕获异常(#714)
|
||||
const store = new Store<StoreType>({
|
||||
name: 'config',
|
||||
defaults: {
|
||||
set: set as SetConfig,
|
||||
shortcuts: createDefaultShortcuts()
|
||||
}
|
||||
});
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化配置管理
|
||||
* 初始化配置管理(幂等:重复调用不会重复注册 IPC 监听)
|
||||
*/
|
||||
export function initializeConfig() {
|
||||
store = new Store<StoreType>({
|
||||
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<Record<string, unknown>> {
|
||||
return store as unknown as Store<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
/**
|
||||
* 获取设备唯一标识符
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private savedState: WindowState | null = null;
|
||||
private isInitialized: boolean = false;
|
||||
private saveStateDebounceTimer: ReturnType<typeof setTimeout> | 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('未找到保存的窗口状态,将使用默认值');
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user