diff --git a/electron/main.ts b/electron/main.ts index 792366f..7e4d381 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -372,6 +372,7 @@ if (process.platform === 'darwin') { let mainWindowReady = false let shouldShowMain = true let isAppQuitting = false +let shutdownPromise: Promise | null = null let tray: Tray | null = null let isClosePromptVisible = false const chatHistoryPayloadStore = new Map() @@ -3869,23 +3870,35 @@ app.whenReady().then(async () => { }) }) -app.on('before-quit', async () => { - isAppQuitting = true - // 销毁 tray 图标 - if (tray) { try { tray.destroy() } catch {} tray = null } - // 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。 - destroyNotificationWindow() - insightService.stop() - // 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留 - const forceExitTimer = setTimeout(() => { - console.warn('[App] Force exit after timeout') - app.exit(0) - }, 5000) - forceExitTimer.unref() - // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 - try { await httpService.stop() } catch {} - // 终止 wcdb Worker 线程,避免线程阻止进程退出 - try { await wcdbService.shutdown() } catch {} +const shutdownAppServices = async (): Promise => { + if (shutdownPromise) return shutdownPromise + shutdownPromise = (async () => { + isAppQuitting = true + // 销毁 tray 图标 + if (tray) { try { tray.destroy() } catch {} tray = null } + // 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。 + destroyNotificationWindow() + messagePushService.stop() + insightService.stop() + // 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留 + const forceExitTimer = setTimeout(() => { + console.warn('[App] Force exit after timeout') + app.exit(0) + }, 5000) + forceExitTimer.unref() + try { await cloudControlService.stop() } catch {} + // 停止 chatService(内部会关闭 cursor 与 DB),避免退出阶段仍触发监控回调 + try { chatService.close() } catch {} + // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 + try { await httpService.stop() } catch {} + // 终止 wcdb Worker 线程,避免线程阻止进程退出 + try { await wcdbService.shutdown() } catch {} + })() + return shutdownPromise +} + +app.on('before-quit', () => { + void shutdownAppServices() }) app.on('window-all-closed', () => { diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts index 39b3345..198c5df 100644 --- a/electron/services/cloudControlService.ts +++ b/electron/services/cloudControlService.ts @@ -218,7 +218,7 @@ class CloudControlService { this.pages.add(pageName) } - stop() { + async stop(): Promise { if (this.timer) { clearTimeout(this.timer) this.timer = null @@ -230,7 +230,13 @@ class CloudControlService { this.circuitOpenedAt = 0 this.nextDelayOverrideMs = null this.initialized = false - wcdbService.cloudStop() + if (wcdbService.isReady()) { + try { + await wcdbService.cloudStop() + } catch { + // 忽略停止失败,避免阻塞主进程退出 + } + } } async getLogs() { diff --git a/electron/services/config.ts b/electron/services/config.ts index 1124c77..0dd422d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -2,6 +2,7 @@ import { app, safeStorage } from 'electron' import crypto from 'crypto' import Store from 'electron-store' +import { expandHomePath } from '../utils/pathUtils' // 加密前缀标记 const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式) @@ -295,6 +296,10 @@ export class ConfigService { return this.decryptWxidConfigs(raw as any) as ConfigSchema[K] } + if (key === 'dbPath' && typeof raw === 'string') { + return expandHomePath(raw) as ConfigSchema[K] + } + return raw } @@ -302,6 +307,10 @@ export class ConfigService { let toStore = value const inLockMode = this.isLockMode() && this.unlockPassword + if (key === 'dbPath' && typeof value === 'string') { + toStore = expandHomePath(value) as ConfigSchema[K] + } + if (ENCRYPTED_BOOL_KEYS.has(key)) { const boolValue = value === true || value === 'true' // `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗 diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index 592c9f9..87fe017 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -2,6 +2,7 @@ import { join, basename } from 'path' import { existsSync, readdirSync, statSync, readFileSync } from 'fs' import { homedir } from 'os' import { createDecipheriv } from 'crypto' +import { expandHomePath } from '../utils/pathUtils' export interface WxidInfo { wxid: string @@ -139,13 +140,14 @@ export class DbPathService { * 查找账号目录(包含 db_storage 或图片目录) */ findAccountDirs(rootPath: string): string[] { + const resolvedRootPath = expandHomePath(rootPath) const accounts: string[] = [] try { - const entries = readdirSync(rootPath) + const entries = readdirSync(resolvedRootPath) for (const entry of entries) { - const entryPath = join(rootPath, entry) + const entryPath = join(resolvedRootPath, entry) let stat: ReturnType try { stat = statSync(entryPath) @@ -216,13 +218,14 @@ export class DbPathService { * 扫描目录名候选(仅包含下划线的文件夹,排除 all_users) */ scanWxidCandidates(rootPath: string): WxidInfo[] { + const resolvedRootPath = expandHomePath(rootPath) const wxids: WxidInfo[] = [] try { - if (existsSync(rootPath)) { - const entries = readdirSync(rootPath) + if (existsSync(resolvedRootPath)) { + const entries = readdirSync(resolvedRootPath) for (const entry of entries) { - const entryPath = join(rootPath, entry) + const entryPath = join(resolvedRootPath, entry) let stat: ReturnType try { stat = statSync(entryPath) } catch { continue } if (!stat.isDirectory()) continue @@ -235,9 +238,9 @@ export class DbPathService { if (wxids.length === 0) { - const rootName = basename(rootPath) + const rootName = basename(resolvedRootPath) if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') { - const rootStat = statSync(rootPath) + const rootStat = statSync(resolvedRootPath) wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs }) } } @@ -248,7 +251,7 @@ export class DbPathService { return a.wxid.localeCompare(b.wxid) }); - const globalInfo = this.parseGlobalConfig(rootPath); + const globalInfo = this.parseGlobalConfig(resolvedRootPath); if (globalInfo) { for (const w of sorted) { if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) { @@ -266,19 +269,20 @@ export class DbPathService { * 扫描 wxid 列表 */ scanWxids(rootPath: string): WxidInfo[] { + const resolvedRootPath = expandHomePath(rootPath) const wxids: WxidInfo[] = [] try { - if (this.isAccountDir(rootPath)) { - const wxid = basename(rootPath) - const modifiedTime = this.getAccountModifiedTime(rootPath) + if (this.isAccountDir(resolvedRootPath)) { + const wxid = basename(resolvedRootPath) + const modifiedTime = this.getAccountModifiedTime(resolvedRootPath) return [{ wxid, modifiedTime }] } - const accounts = this.findAccountDirs(rootPath) + const accounts = this.findAccountDirs(resolvedRootPath) for (const account of accounts) { - const fullPath = join(rootPath, account) + const fullPath = join(resolvedRootPath, account) const modifiedTime = this.getAccountModifiedTime(fullPath) wxids.push({ wxid: account, modifiedTime }) } @@ -289,7 +293,7 @@ export class DbPathService { return a.wxid.localeCompare(b.wxid) }); - const globalInfo = this.parseGlobalConfig(rootPath); + const globalInfo = this.parseGlobalConfig(resolvedRootPath); if (globalInfo) { for (const w of sorted) { if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) { diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index ca7e057..3870ac0 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -53,6 +53,13 @@ class MessagePushService { void this.refreshConfiguration('startup') } + stop(): void { + this.started = false + this.processing = false + this.rerunRequested = false + this.resetRuntimeState() + } + handleDbMonitorChange(type: string, json: string): void { if (!this.started) return if (!this.isPushEnabled()) return diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 116ba45..0f6a84b 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -2,6 +2,7 @@ import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { tmpdir } from 'os' import * as fzstd from 'fzstd' +import { expandHomePath } from '../utils/pathUtils' //数据服务初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null @@ -481,7 +482,7 @@ export class WcdbCore { private resolveDbStoragePath(basePath: string, wxid: string): string | null { if (!basePath) return null - const normalized = basePath.replace(/[\\\\/]+$/, '') + const normalized = expandHomePath(basePath).replace(/[\\\\/]+$/, '') if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) { return normalized } @@ -1600,6 +1601,9 @@ export class WcdbCore { */ close(): void { if (this.handle !== null || this.initialized) { + // 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。 + try { this.stopMonitor() } catch {} + try { this.cloudStop() } catch {} try { // 不调用 closeAccount,直接 shutdown this.wcdbShutdown() diff --git a/electron/utils/pathUtils.ts b/electron/utils/pathUtils.ts new file mode 100644 index 0000000..c1fb638 --- /dev/null +++ b/electron/utils/pathUtils.ts @@ -0,0 +1,20 @@ +import { homedir } from 'os' + +/** + * Expand "~" prefix to current user's home directory. + * Examples: + * - "~" => "/Users/alex" + * - "~/Library/..." => "/Users/alex/Library/..." + */ +export function expandHomePath(inputPath: string): string { + const raw = String(inputPath || '').trim() + if (!raw) return raw + + if (raw === '~') return homedir() + if (/^~[\\/]/.test(raw)) { + return `${homedir()}${raw.slice(1)}` + } + + return raw +} +