diff --git a/WeFlow b/WeFlow index 4da9f1e..57fad47 160000 --- a/WeFlow +++ b/WeFlow @@ -1 +1 @@ -Subproject commit 4da9f1e6cfa9854d89801aa55661b60a12b7b368 +Subproject commit 57fad47f279620ca22ce0870996b4537f8fba75b diff --git a/electron/main.ts b/electron/main.ts index 889f39c..ac59611 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, nativeTheme, protocol, net, Tray, Menu } from 'electron' +import { app, BrowserWindow, ipcMain, nativeImage, nativeTheme, protocol, net, Tray, Menu } from 'electron' import { join } from 'path' import { randomBytes } from 'crypto' import { readFileSync, existsSync, mkdirSync } from 'fs' @@ -183,15 +183,54 @@ function getAppIconPath(): string { } } +function getDockIconPath(): string { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconName = configService?.get('appIcon') || 'default' + + if (iconName === 'xinnian') { + const devPaddedPath = join(__dirname, '../public/xinnian-dock.png') + const devFallbackPath = join(__dirname, '../public/xinnian.png') + return isDev + ? (existsSync(devPaddedPath) ? devPaddedPath : devFallbackPath) + : join(process.resourcesPath, 'icon.png') + } + + const devPaddedPath = join(__dirname, '../public/icon-dock.png') + const devFallbackPath = join(__dirname, '../public/logo.png') + return isDev + ? (existsSync(devPaddedPath) ? devPaddedPath : devFallbackPath) + : join(process.resourcesPath, 'icon.png') +} + +function getTrayIconPath(): string { + if (process.platform === 'darwin') { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconName = configService?.get('appIcon') || 'default' + const devTrayPath = iconName === 'xinnian' + ? join(__dirname, '../public/xinnian-tray.png') + : join(__dirname, '../public/tray-mac.png') + + if (isDev && existsSync(devTrayPath)) { + return devTrayPath + } + } + + return getAppIconPath() +} + /** * 创建系统托盘 */ function createTray() { if (tray) return tray - const iconPath = getAppIconPath() + const iconPath = getTrayIconPath() tray = new Tray(iconPath) + if (process.platform === 'darwin') { + tray.setIgnoreDoubleClickEvents(true) + } + const contextMenu = Menu.buildFromTemplate([ { label: '显示主窗口', @@ -1406,14 +1445,20 @@ function registerIpcHandlers() { ipcMain.handle('app:setAppIcon', async (_, iconName: string) => { try { - const iconPath = getAppIconPath() + const iconPath = process.platform === 'darwin' ? getDockIconPath() : getAppIconPath() if (existsSync(iconPath)) { - const { nativeImage } = require('electron') const image = nativeImage.createFromPath(iconPath) - BrowserWindow.getAllWindows().forEach(win => { - win.setIcon(image) - }) + + if (process.platform === 'darwin') { + if (!image.isEmpty()) { + app.dock.setIcon(image) + } + } else { + BrowserWindow.getAllWindows().forEach(win => { + win.setIcon(image) + }) + } // 尝试更新桌面快捷方式图标 (不阻塞主线程) shortcutService.updateDesktopShortcutIcon(iconPath).catch(err => { @@ -1713,20 +1758,111 @@ function registerIpcHandlers() { return wxKeyService.waitForWeChatWindow(maxWaitSeconds) }) - ipcMain.handle('wxkey:startGetKey', async (event, customWechatPath?: string) => { + ipcMain.handle('wxkey:startGetKey', async (event, customWechatPath?: string, dbPath?: string) => { logService?.info('WxKey', '开始获取微信密钥', { customWechatPath }) if (process.platform === 'darwin') { try { + const isRunning = wxKeyServiceMac.isWeChatRunning() + if (isRunning) { + event.sender.send('wxkey:status', { status: '检测到微信正在运行,正在关闭微信...', level: 0 }) + wxKeyServiceMac.killWeChat() + + const exited = await wxKeyServiceMac.waitForWeChatExit(20) + if (!exited) { + return { success: false, error: '未能自动关闭微信,请先手动退出微信后重试' } + } + + event.sender.send('wxkey:status', { status: '微信已关闭,正在重新启动微信...', level: 0 }) + const relaunched = await wxKeyServiceMac.launchWeChat(customWechatPath) + if (!relaunched) { + return { success: false, error: '微信关闭后自动重启失败' } + } + + event.sender.send('wxkey:status', { status: '微信已重新启动,等待主进程就绪...', level: 0 }) + const ready = await wxKeyServiceMac.waitForWeChatWindow(20) + if (!ready) { + return { success: false, error: '微信已重新启动,但未检测到可用主进程,请确认微信已完成启动并显示主窗口' } + } + } else { + event.sender.send('wxkey:status', { status: '未检测到微信主进程,正在尝试启动微信...', level: 0 }) + + const launched = await wxKeyServiceMac.launchWeChat(customWechatPath) + if (!launched) { + return { success: false, error: '未找到微信主进程,且自动启动微信失败' } + } + + event.sender.send('wxkey:status', { status: '微信已启动,等待主进程就绪...', level: 0 }) + const ready = await wxKeyServiceMac.waitForWeChatWindow(20) + if (!ready) { + return { success: false, error: '微信已启动,但未检测到可用主进程,请确认微信已完成启动并显示主窗口' } + } + } + const result = await wxKeyServiceMac.autoGetDbKey(180_000, (status, level) => { event.sender.send('wxkey:status', { status, level }) }) - if (result.success) { - logService?.info('WxKey', 'macOS 数据库密钥获取成功', { keyLength: result.key?.length || 0 }) - } else { + if (!result.success) { logService?.warn('WxKey', 'macOS 数据库密钥获取失败', { error: result.error }) + return result } + if (result.key && dbPath) { + event.sender.send('wxkey:status', { status: '已获取候选密钥,正在验证数据库...', level: 0 }) + + const wxidCandidates: string[] = [] + const pushWxid = (value?: string | null) => { + const wxid = String(value || '').trim() + if (!wxid || wxidCandidates.includes(wxid)) return + wxidCandidates.push(wxid) + } + + let currentAccount = wxKeyServiceMac.detectCurrentAccount(dbPath, 10) + if (!currentAccount) { + currentAccount = wxKeyServiceMac.detectCurrentAccount(dbPath, 60) + } + pushWxid(currentAccount?.wxid) + + try { + const scannedWxids = dbPathService.scanWxids(dbPath) + for (const wxid of scannedWxids) { + pushWxid(wxid) + } + } catch { + // ignore + } + + let validatedWxid = '' + let lastError = '' + for (const wxid of wxidCandidates) { + event.sender.send('wxkey:status', { status: `正在验证账号目录: ${wxid}`, level: 0 }) + const testResult = await wcdbService.testConnection(dbPath, result.key, wxid) + if (testResult.success) { + validatedWxid = wxid + break + } + lastError = testResult.error || '' + } + + if (!validatedWxid) { + logService?.warn('WxKey', 'macOS 候选密钥未通过数据库验证', { + dbPath, + candidateCount: wxidCandidates.length + }) + return { + success: false, + error: lastError || '已捕获到候选密钥,但未通过数据库验证。请在微信完成登录后进入任意聊天,让数据库访问真正触发,再重试。' + } + } + + logService?.info('WxKey', 'macOS 候选密钥已通过数据库验证', { dbPath, wxid: validatedWxid }) + return { + ...result, + validatedWxid + } + } + + logService?.info('WxKey', 'macOS 数据库密钥获取成功', { keyLength: result.key?.length || 0 }) return result } catch (e) { wxKeyServiceMac.dispose() @@ -1889,6 +2025,26 @@ function registerIpcHandlers() { return result }) + ipcMain.handle('wcdb:resolveValidWxid', async (_, dbPath: string, hexKey: string) => { + try { + const wxids = dbPathService.scanWxids(dbPath) + if (wxids.length === 0) { + return { success: false, error: '未检测到账号目录' } + } + + for (const wxid of wxids) { + const result = await wcdbService.testConnection(dbPath, hexKey, wxid, true) + if (result.success) { + return { success: true, wxid } + } + } + + return { success: false, error: '未找到可通过当前密钥验证的账号目录' } + } catch (e) { + return { success: false, error: String(e) } + } + }) + ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => { return wcdbService.open(dbPath, hexKey, wxid) }) @@ -3963,6 +4119,16 @@ app.whenReady().then(async () => { configService = new ConfigService() } + if (process.platform === 'darwin') { + const dockIconPath = getDockIconPath() + if (existsSync(dockIconPath)) { + const dockIcon = nativeImage.createFromPath(dockIconPath) + if (!dockIcon.isEmpty()) { + app.dock.setIcon(dockIcon) + } + } + } + if (!configService.get('mcpProxyToken')) { configService.set('mcpProxyToken', randomBytes(24).toString('hex')) } diff --git a/electron/preload.ts b/electron/preload.ts index fc364c7..66435c0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -176,7 +176,7 @@ contextBridge.exposeInMainWorld('electronAPI', { killWeChat: () => ipcRenderer.invoke('wxkey:killWeChat'), launchWeChat: () => ipcRenderer.invoke('wxkey:launchWeChat'), waitForWindow: (maxWaitSeconds?: number) => ipcRenderer.invoke('wxkey:waitForWindow', maxWaitSeconds), - startGetKey: (customWechatPath?: string) => ipcRenderer.invoke('wxkey:startGetKey', customWechatPath), + startGetKey: (customWechatPath?: string, dbPath?: string) => ipcRenderer.invoke('wxkey:startGetKey', customWechatPath, dbPath), cancel: () => ipcRenderer.invoke('wxkey:cancel'), detectCurrentAccount: (dbPath?: string, maxTimeDiffMinutes?: number) => ipcRenderer.invoke('wxkey:detectCurrentAccount', dbPath, maxTimeDiffMinutes), onStatus: (callback: (data: { status: string; level: number }) => void) => { @@ -197,6 +197,8 @@ contextBridge.exposeInMainWorld('electronAPI', { wcdb: { testConnection: (dbPath: string, hexKey: string, wxid: string, isAutoConnect?: boolean) => ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid, isAutoConnect), + resolveValidWxid: (dbPath: string, hexKey: string) => + ipcRenderer.invoke('wcdb:resolveValidWxid', dbPath, hexKey), open: (dbPath: string, hexKey: string, wxid: string) => ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid), close: () => ipcRenderer.invoke('wcdb:close'), diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index e090985..534b67d 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -2,19 +2,19 @@ import { basename, join } from 'path' import { existsSync, readdirSync, statSync } from 'fs' import { homedir } from 'os' +type PathCandidate = { + path: string + accountCount: number + latestModified: number + score: number +} + export class DbPathService { async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> { try { - for (const candidate of this.getPossibleRoots()) { - if (!existsSync(candidate)) continue - - if (this.isAccountDir(candidate)) { - return { success: true, path: candidate } - } - - if (this.findAccountDirs(candidate).length > 0) { - return { success: true, path: candidate } - } + const candidates = this.collectCandidates() + if (candidates.length > 0) { + return { success: true, path: candidates[0].path } } return { success: false, error: '未能自动检测到微信数据库目录' } @@ -36,6 +36,9 @@ export class DbPathService { getDefaultPath(): string { const home = homedir() + const detected = this.collectCandidates()[0] + if (detected) return detected.path + if (process.platform === 'darwin') { const appSupportBase = join( home, @@ -96,6 +99,95 @@ export class DbPathService { ] } + private collectCandidates(): PathCandidate[] { + const candidates: PathCandidate[] = [] + const seen = new Set() + + const pushCandidate = (candidatePath: string) => { + const normalized = String(candidatePath || '').replace(/[\\/]+$/, '') + if (!normalized || seen.has(normalized) || !existsSync(normalized)) return + seen.add(normalized) + + if (this.isAccountDir(normalized)) { + const latestModified = this.getAccountModifiedTime(normalized) + candidates.push({ + path: normalized, + accountCount: 1, + latestModified, + score: 1_000_000 + latestModified + }) + return + } + + const accounts = this.findAccountDirs(normalized) + if (accounts.length === 0) return + + let latestModified = 0 + for (const account of accounts) { + latestModified = Math.max(latestModified, this.getAccountModifiedTime(join(normalized, account))) + } + + const rootName = basename(normalized).toLowerCase() + const rootBonus = + process.platform === 'darwin' && this.isMacVersionDir(rootName) ? 50_000 : + rootName === 'xwechat_files' ? 30_000 : + rootName === 'wechat files' ? 20_000 : + 0 + + candidates.push({ + path: normalized, + accountCount: accounts.length, + latestModified, + score: rootBonus + accounts.length * 10_000 + latestModified + }) + } + + for (const candidate of this.getPossibleRoots()) { + pushCandidate(candidate) + } + + if (process.platform === 'darwin') { + for (const candidate of this.getMacNestedRoots()) { + pushCandidate(candidate) + } + } + + return candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score + return a.path.localeCompare(b.path) + }) + } + + private getMacNestedRoots(): string[] { + const home = homedir() + const appSupportBase = join( + home, + 'Library', + 'Containers', + 'com.tencent.xinWeChat', + 'Data', + 'Library', + 'Application Support', + 'com.tencent.xinWeChat' + ) + + const nestedRoots: string[] = [] + + for (const entry of this.safeReadDir(appSupportBase)) { + if (!this.isMacVersionDir(entry)) continue + + const versionDir = join(appSupportBase, entry) + nestedRoots.push(versionDir) + + for (const child of this.safeReadDir(versionDir)) { + if (!this.isPotentialAccountName(child)) continue + nestedRoots.push(join(versionDir, child)) + } + } + + return nestedRoots + } + private findAccountDirs(rootPath: string): string[] { const accounts: string[] = [] diff --git a/electron/services/shortcutService.darwin.ts b/electron/services/shortcutService.darwin.ts new file mode 100644 index 0000000..05e49c6 --- /dev/null +++ b/electron/services/shortcutService.darwin.ts @@ -0,0 +1,10 @@ +import type { ShortcutService, ShortcutUpdateResult } from './shortcutService' + +class DarwinShortcutService implements ShortcutService { + async updateDesktopShortcutIcon(_iconPath: string): Promise { + // macOS 没有与 Windows .lnk 对应的统一桌面快捷方式图标更新入口,这里保持成功返回即可。 + return { success: true } + } +} + +export const shortcutService = new DarwinShortcutService() diff --git a/electron/services/shortcutService.ts b/electron/services/shortcutService.ts index 4aa8823..374bb9a 100644 --- a/electron/services/shortcutService.ts +++ b/electron/services/shortcutService.ts @@ -1,79 +1,21 @@ -import { app } from 'electron' -import { spawn } from 'child_process' -import { join } from 'path' -import { existsSync } from 'fs' - -export class ShortcutService { - /** - * 更新桌面快捷方式的图标 - * 注意:这需要调用 PowerShell,可能会短暂显示控制台窗口或被杀毒软件拦截 - * @param iconPath ICO 图标文件的绝对路径 - */ - async updateDesktopShortcutIcon(iconPath: string): Promise<{ success: boolean; error?: string }> { - return new Promise((resolve) => { - try { - if (!existsSync(iconPath)) { - resolve({ success: false, error: '图标文件不存在' }) - return - } - - const desktopPath = app.getPath('desktop') - const exePath = process.execPath - - // PowerShell 脚本:遍历桌面所有 .lnk,如果目标指向当前 exe,则修改图标 - // 使用 -WindowStyle Hidden 隐藏窗口 - const psScript = ` - $WshShell = New-Object -comObject WScript.Shell - $DesktopPath = "${desktopPath}" - $TargetExe = "${exePath}" - $IconPath = "${iconPath}" - - Get-ChildItem -Path $DesktopPath -Filter *.lnk | ForEach-Object { - try { - $Shortcut = $WshShell.CreateShortcut($_.FullName) - if ($Shortcut.TargetPath -eq $TargetExe) { - $Shortcut.IconLocation = $IconPath - $Shortcut.Save() - Write-Host "Updated: $($_.Name)" - } - } catch { - Write-Error $_.Exception.Message - } - } - ` - - const ps = spawn('powershell.exe', [ - '-NoProfile', - '-ExecutionPolicy', 'Bypass', - '-WindowStyle', 'Hidden', - '-Command', psScript - ]) - - let output = '' - let errorOutput = '' - - ps.stdout.on('data', (data) => { - output += data.toString() - }) - - ps.stderr.on('data', (data) => { - errorOutput += data.toString() - }) - - ps.on('close', (code) => { - if (code === 0) { - resolve({ success: true }) - } else { - console.error('[ShortcutService] 更新快捷方式失败', errorOutput) - resolve({ success: false, error: errorOutput || 'Unknown PowerShell error' }) - } - }) - } catch (e) { - console.error('[ShortcutService] 执行出错', e) - resolve({ success: false, error: String(e) }) - } - }) - } +export type ShortcutUpdateResult = { + success: boolean + error?: string } -export const shortcutService = new ShortcutService() +export interface ShortcutService { + updateDesktopShortcutIcon(iconPath: string): Promise +} + +import { shortcutService as windowsShortcutService } from './shortcutService.win32' +import { shortcutService as darwinShortcutService } from './shortcutService.darwin' +import { shortcutService as unsupportedShortcutService } from './shortcutService.unsupported' + +const shortcutService: ShortcutService = + process.platform === 'win32' + ? windowsShortcutService + : process.platform === 'darwin' + ? darwinShortcutService + : unsupportedShortcutService + +export { shortcutService } diff --git a/electron/services/shortcutService.unsupported.ts b/electron/services/shortcutService.unsupported.ts new file mode 100644 index 0000000..699ea46 --- /dev/null +++ b/electron/services/shortcutService.unsupported.ts @@ -0,0 +1,12 @@ +import type { ShortcutService, ShortcutUpdateResult } from './shortcutService' + +class UnsupportedShortcutService implements ShortcutService { + async updateDesktopShortcutIcon(_iconPath: string): Promise { + return { + success: false, + error: `Desktop shortcut icon update is not supported on ${process.platform}` + } + } +} + +export const shortcutService = new UnsupportedShortcutService() diff --git a/electron/services/shortcutService.win32.ts b/electron/services/shortcutService.win32.ts new file mode 100644 index 0000000..db810d4 --- /dev/null +++ b/electron/services/shortcutService.win32.ts @@ -0,0 +1,72 @@ +import { app } from 'electron' +import { spawn } from 'child_process' +import { existsSync } from 'fs' +import type { ShortcutService, ShortcutUpdateResult } from './shortcutService' + +class WindowsShortcutService implements ShortcutService { + async updateDesktopShortcutIcon(iconPath: string): Promise { + return new Promise((resolve) => { + try { + if (!existsSync(iconPath)) { + resolve({ success: false, error: '图标文件不存在' }) + return + } + + const desktopPath = app.getPath('desktop') + const exePath = process.execPath + const psScript = ` + $WshShell = New-Object -comObject WScript.Shell + $DesktopPath = "${desktopPath}" + $TargetExe = "${exePath}" + $IconPath = "${iconPath}" + + Get-ChildItem -Path $DesktopPath -Filter *.lnk | ForEach-Object { + try { + $Shortcut = $WshShell.CreateShortcut($_.FullName) + if ($Shortcut.TargetPath -eq $TargetExe) { + $Shortcut.IconLocation = $IconPath + $Shortcut.Save() + Write-Host "Updated: $($_.Name)" + } + } catch { + Write-Error $_.Exception.Message + } + } + ` + + const ps = spawn('powershell.exe', [ + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-WindowStyle', 'Hidden', + '-Command', psScript + ]) + + let errorOutput = '' + + ps.stderr.on('data', (data) => { + errorOutput += data.toString() + }) + + ps.on('error', (error) => { + console.error('[ShortcutService] PowerShell 启动失败', error) + resolve({ success: false, error: String(error) }) + }) + + ps.on('close', (code) => { + if (code === 0) { + resolve({ success: true }) + return + } + + console.error('[ShortcutService] 更新快捷方式失败', errorOutput) + resolve({ success: false, error: errorOutput || 'Unknown PowerShell error' }) + }) + } catch (e) { + console.error('[ShortcutService] 执行出错', e) + resolve({ success: false, error: String(e) }) + } + }) + } +} + +export const shortcutService = new WindowsShortcutService() diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 26b794d..0aad436 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -7,6 +7,10 @@ export class WcdbService { private koffi: any = null private initialized = false private handle: number | null = null + private currentPath: string | null = null + private currentKey: string | null = null + private currentWxid: string | null = null + private currentDbStoragePath: string | null = null private wcdbInit: any = null private wcdbShutdown: any = null @@ -37,8 +41,8 @@ export class WcdbService { return join(baseDir, 'WCDB.dll') } - private findSessionDb(dir: string, depth = 0): string | null { - if (depth > 5) return null + private findSessionDbs(dir: string, depth = 0, results: string[] = []): string[] { + if (depth > 5) return results try { const entries = readdirSync(dir) @@ -46,8 +50,8 @@ export class WcdbService { for (const entry of entries) { if (entry.toLowerCase() === 'session.db') { const fullPath = join(dir, entry) - if (statSync(fullPath).isFile()) { - return fullPath + if (statSync(fullPath).isFile() && !results.includes(fullPath)) { + results.push(fullPath) } } } @@ -56,8 +60,7 @@ export class WcdbService { const fullPath = join(dir, entry) try { if (statSync(fullPath).isDirectory()) { - const found = this.findSessionDb(fullPath, depth + 1) - if (found) return found + this.findSessionDbs(fullPath, depth + 1, results) } } catch { // ignore @@ -67,92 +70,89 @@ export class WcdbService { console.error('查找 session.db 失败:', e) } - return null + return results } - private normalizeWxid(wxid: string): string { - const trimmed = String(wxid || '').trim() - if (!trimmed) return '' - - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - return match?.[1] || trimmed - } - - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - return suffixMatch ? suffixMatch[1] : trimmed + private scoreSessionDbPath(filePath: string): number { + const normalized = filePath.replace(/\\/g, '/').toLowerCase() + let score = 0 + if (normalized.endsWith('/session/session.db')) score += 40 + if (normalized.includes('/db_storage/session/')) score += 20 + if (normalized.includes('/db_storage/')) score += 10 + return score } - private isAccountDir(dirPath: string): boolean { - return ( - existsSync(join(dirPath, 'db_storage')) || - existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) || - existsSync(join(dirPath, 'msg', 'attach')) - ) + private getCandidateSessionDbs(dbStoragePath: string): string[] { + return this.findSessionDbs(dbStoragePath) + .sort((a, b) => this.scoreSessionDbPath(b) - this.scoreSessionDbPath(a) || a.localeCompare(b)) } - private resolveAccountRoot(dbPath: string, wxid: string): string | null { - const normalizedDbPath = dbPath.replace(/[\\/]+$/, '') - const direct = join(normalizedDbPath, wxid) - if (existsSync(direct) && this.isAccountDir(direct)) { - return direct - } + private tryOpenWithCandidates(sessionDbPaths: string[], hexKey: string): { success: boolean; handle?: number; matchedPath?: string; errors: string[] } { + const errors: string[] = [] - const normalizedWxid = this.normalizeWxid(wxid) - const directNormalized = join(normalizedDbPath, normalizedWxid) - if (existsSync(directNormalized) && this.isAccountDir(directNormalized)) { - return directNormalized - } - - if (this.isAccountDir(normalizedDbPath) && basename(normalizedDbPath) === wxid) { - return normalizedDbPath - } - - if (this.isAccountDir(normalizedDbPath) && basename(normalizedDbPath) === normalizedWxid) { - return normalizedDbPath - } - - try { - for (const entry of readdirSync(normalizedDbPath, { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const entryPath = join(normalizedDbPath, entry.name) - if (!this.isAccountDir(entryPath)) continue - - const lowerEntry = entry.name.toLowerCase() - const lowerWxid = wxid.toLowerCase() - const lowerNormalizedWxid = normalizedWxid.toLowerCase() - const cleanedEntry = this.normalizeWxid(entry.name).toLowerCase() - - if ( - lowerEntry === lowerWxid || - lowerEntry === lowerNormalizedWxid || - cleanedEntry === lowerWxid || - cleanedEntry === lowerNormalizedWxid || - lowerEntry.startsWith(`${lowerWxid}_`) || - lowerEntry.startsWith(`${lowerNormalizedWxid}_`) - ) { - return entryPath + for (const sessionDbPath of sessionDbPaths) { + const handleOut = [0] + const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) + if (result === 0 && handleOut[0] > 0) { + return { + success: true, + handle: handleOut[0], + matchedPath: sessionDbPath, + errors } } - } catch { - // ignore + + errors.push(`${sessionDbPath} => ${this.mapStatusCode(result)}`) } - return null + return { success: false, errors } } private resolveDbStoragePath(dbPath: string, wxid: string): string | null { + if (!dbPath) return null + const normalizedDbPath = dbPath.replace(/[\\/]+$/, '') if (basename(normalizedDbPath).toLowerCase() === 'db_storage' && existsSync(normalizedDbPath)) { return normalizedDbPath } - const accountRoot = this.resolveAccountRoot(normalizedDbPath, wxid) - if (!accountRoot) return null + const direct = join(normalizedDbPath, 'db_storage') + if (existsSync(direct)) { + return direct + } - const dbStoragePath = join(accountRoot, 'db_storage') - return existsSync(dbStoragePath) ? dbStoragePath : null + if (wxid) { + const viaWxid = join(normalizedDbPath, wxid, 'db_storage') + if (existsSync(viaWxid)) { + return viaWxid + } + + try { + const lowerWxid = wxid.toLowerCase() + for (const entry of readdirSync(normalizedDbPath)) { + const entryPath = join(normalizedDbPath, entry) + try { + if (!statSync(entryPath).isDirectory()) continue + } catch { + continue + } + + const lowerEntry = entry.toLowerCase() + if (lowerEntry !== lowerWxid && !lowerEntry.startsWith(`${lowerWxid}_`)) { + continue + } + + const candidate = join(entryPath, 'db_storage') + if (existsSync(candidate)) { + return candidate + } + } + } catch { + // ignore + } + } + + return null } private async initialize(): Promise<{ success: boolean; error?: string }> { @@ -201,6 +201,20 @@ export class WcdbService { async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { try { + if ( + this.handle !== null && + this.currentPath === dbPath && + this.currentKey === hexKey && + this.currentWxid === wxid + ) { + return { success: true, sessionCount: 0 } + } + + const hadActiveConnection = this.handle !== null + const prevPath = this.currentPath + const prevKey = this.currentKey + const prevWxid = this.currentWxid + const initRes = await this.initialize() if (!initRes.success) { return { success: false, error: initRes.error || 'WCDB 初始化失败' } @@ -211,24 +225,45 @@ export class WcdbService { return { success: false, error: `未找到账号目录或 db_storage: ${dbPath}` } } - const sessionDbPath = this.findSessionDb(dbStoragePath) - if (!sessionDbPath) { - return { success: false, error: '未找到 session.db 文件' } + const sessionDbPaths = this.getCandidateSessionDbs(dbStoragePath) + if (sessionDbPaths.length === 0) { + return { success: false, error: `未找到 session.db 文件: ${dbStoragePath}` } } - const handleOut = [0] - const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) - if (result !== 0) { - await this.printLogs() - return { success: false, error: this.mapStatusCode(result) } + const openResult = this.tryOpenWithCandidates(sessionDbPaths, hexKey) + if (!openResult.success || !openResult.handle || !openResult.matchedPath) { + const logs = await this.printLogs() + return { + success: false, + error: `数据库打开失败 | db_storage=${dbStoragePath} | tried=${sessionDbPaths.join(', ')}${openResult.errors.length ? ` | details=${openResult.errors.join(' ; ')}` : ''}${logs ? ` | logs=${logs}` : ''}` + } } - const handle = handleOut[0] - if (handle <= 0) { + const tempHandle = openResult.handle + if (tempHandle <= 0) { return { success: false, error: '无效的数据库句柄' } } - this.handle = handle + try { + this.wcdbShutdown() + this.handle = null + this.currentPath = null + this.currentKey = null + this.currentWxid = null + this.currentDbStoragePath = null + this.initialized = false + } catch (e) { + console.error('关闭测试数据库时出错:', e) + } + + if (hadActiveConnection && prevPath && prevKey && prevWxid) { + try { + await this.open(prevPath, prevKey, prevWxid) + } catch { + // ignore restore failure + } + } + return { success: true, sessionCount: 0 } } catch (e) { console.error('测试连接异常:', e) @@ -237,8 +272,63 @@ export class WcdbService { } async open(dbPath: string, hexKey: string, wxid: string): Promise { - const result = await this.testConnection(dbPath, hexKey, wxid) - return result.success + try { + if ( + this.handle !== null && + this.currentPath === dbPath && + this.currentKey === hexKey && + this.currentWxid === wxid + ) { + return true + } + + const initRes = await this.initialize() + if (!initRes.success) { + return false + } + + if (this.handle !== null) { + this.close() + const reinitRes = await this.initialize() + if (!reinitRes.success) { + return false + } + } + + const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) + if (!dbStoragePath) { + console.error('数据库目录不存在:', dbPath) + return false + } + + const sessionDbPaths = this.getCandidateSessionDbs(dbStoragePath) + if (sessionDbPaths.length === 0) { + console.error('未找到 session.db 文件:', dbStoragePath) + return false + } + + const openResult = this.tryOpenWithCandidates(sessionDbPaths, hexKey) + if (!openResult.success || !openResult.handle) { + await this.printLogs() + return false + } + + const handle = openResult.handle + if (handle <= 0) { + return false + } + + this.handle = handle + this.currentPath = dbPath + this.currentKey = hexKey + this.currentWxid = wxid + this.currentDbStoragePath = dbStoragePath + this.initialized = true + return true + } catch (e) { + console.error('打开数据库异常:', e) + return false + } } close(): void { @@ -261,6 +351,10 @@ export class WcdbService { this.handle = null this.initialized = false this.lib = null + this.currentPath = null + this.currentKey = null + this.currentWxid = null + this.currentDbStoragePath = null } shutdown(): void { @@ -330,19 +424,21 @@ export class WcdbService { return encryptedData } - private async printLogs(): Promise { + private async printLogs(): Promise { try { - if (!this.wcdbGetLogs) return + if (!this.wcdbGetLogs) return '' const outPtr = [null as any] const result = this.wcdbGetLogs(outPtr) if (result === 0 && outPtr[0]) { const jsonStr = this.koffi.decode(outPtr[0], 'char', -1) console.error('WCDB 内部日志:', jsonStr) this.wcdbFreeString(outPtr[0]) + return jsonStr } } catch (e) { console.error('获取 WCDB 日志失败:', e) } + return '' } private mapStatusCode(code: number): string { diff --git a/electron/services/wxKeyServiceMac.ts b/electron/services/wxKeyServiceMac.ts index 14e13bb..6d99c4a 100644 --- a/electron/services/wxKeyServiceMac.ts +++ b/electron/services/wxKeyServiceMac.ts @@ -81,16 +81,8 @@ export class WxKeyServiceMac { } async initialize(): Promise { - if (this.initialized) return true - try { - this.koffi = require('koffi') - const dylibPath = this.getDylibPath() - this.lib = this.koffi.load(dylibPath) - this.GetDbKey = this.lib.func('const char* GetDbKey()') - this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()') - this.initialized = true - return true + return this.initializeFromRuntime() } catch (e) { console.error('[WxKeyServiceMac] 初始化失败:', e) return false @@ -127,6 +119,16 @@ export class WxKeyServiceMac { // ignore } + try { + if (this.initializeFromRuntime()) { + const raw = this.ListWeChatProcesses?.() + const parsed = this.parseWeChatProcessList(typeof raw === 'string' ? raw : '') + if (parsed.length > 0) return Math.max(...parsed) + } + } catch { + // ignore + } + try { const output = execSync('/bin/ps -A -o pid,comm,command', { encoding: 'utf8' }) const lines = output.split(/\r?\n/).slice(1) @@ -156,6 +158,39 @@ export class WxKeyServiceMac { return null } + private initializeFromRuntime(): boolean { + if (this.initialized) return true + + try { + this.koffi = require('koffi') + const dylibPath = this.getDylibPath() + this.lib = this.koffi.load(dylibPath) + this.GetDbKey = this.lib.func('const char* GetDbKey()') + this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()') + this.initialized = true + return true + } catch { + return false + } + } + + private parseWeChatProcessList(raw: string): number[] { + return String(raw || '') + .split(';') + .map(item => item.trim()) + .filter(Boolean) + .map(item => { + const lastColon = item.lastIndexOf(':') + if (lastColon < 0) return null + const name = item.slice(0, lastColon) + const pid = Number(item.slice(lastColon + 1)) + if (!Number.isFinite(pid) || pid <= 0) return null + if (name.includes('Helper') || name.includes('crashpad_handler') || name.includes('WeChatAppEx')) return null + return pid + }) + .filter((pid): pid is number => pid !== null) + } + killWeChat(): boolean { try { execSync('/usr/bin/pkill -x WeChat', { stdio: 'ignore' }) @@ -165,6 +200,16 @@ export class WxKeyServiceMac { } } + async waitForWeChatExit(maxWaitSeconds = 15): Promise { + for (let i = 0; i < maxWaitSeconds * 2; i++) { + if (!this.isWeChatRunning()) { + return true + } + await new Promise(resolve => setTimeout(resolve, 500)) + } + return !this.isWeChatRunning() + } + async launchWeChat(customPath?: string): Promise { try { if (customPath && existsSync(customPath)) { @@ -198,18 +243,20 @@ export class WxKeyServiceMac { if (sipStatus.enabled) { return { success: false, - error: 'SIP 已开启,无法抓取 macOS 微信数据库密钥。请先关闭 SIP 后重试。' + error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑' } } - onStatus?.('正在请求管理员授权并启动 helper...', 0) + onStatus?.('正在获取数据库密钥...', 0) + onStatus?.('正在请求管理员授权并执行 helper...', 0) let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string } try { const helperResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus) parsed = this.parseDbKeyResult(helperResult) + console.log('[WxKeyServiceMac] GetDbKey elevated returned:', parsed.raw) } catch (e: any) { - const msg = String(e?.message || e) + const msg = `${e?.message || e}` if (msg.includes('(-128)') || msg.includes('User canceled')) { return { success: false, error: '已取消管理员授权' } } @@ -217,9 +264,11 @@ export class WxKeyServiceMac { } if (!parsed.success) { + const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail) + onStatus?.(errorMsg, 2) return { success: false, - error: this.mapDbKeyErrorMessage(parsed.code, parsed.detail) + error: errorMsg } } @@ -227,8 +276,9 @@ export class WxKeyServiceMac { return { success: true, key: parsed.key } } catch (e: any) { console.error('[WxKeyServiceMac] 获取密钥失败:', e) + console.error('[WxKeyServiceMac] Stack:', e.stack) onStatus?.(`获取失败: ${e.message}`, 2) - return { success: false, error: e.message || String(e) } + return { success: false, error: e.message } } } @@ -251,7 +301,7 @@ export class WxKeyServiceMac { `set timeoutSec to ${timeoutSec}`, 'try', 'with timeout of timeoutSec seconds', - 'set outText to do shell script (cmd & " 2>&1") with administrator privileges', + 'set outText to do shell script cmd with administrator privileges', 'end timeout', 'return "WF_OK::" & outText', 'on error errMsg number errNum partial result pr', @@ -259,42 +309,47 @@ export class WxKeyServiceMac { 'end try' ] - onStatus?.(`已找到微信进程 PID=${pid},正在请求管理员授权...`, 0) + onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0) - const result = await execFileAsync( - '/usr/bin/osascript', - scriptLines.flatMap(line => ['-e', line]), - { timeout: waitMs + 20_000 } - ) - - const lines = String(result.stdout || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean) - if (lines.length === 0) { - throw new Error('helper 返回空输出') + let stdout = '' + try { + const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { + timeout: waitMs + 20_000 + }) + stdout = result.stdout + } catch (e: any) { + const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim() + throw new Error(msg || 'elevated helper execution failed') } + const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean) + if (!lines.length) throw new Error('elevated helper returned empty output') + const joined = lines.join('\n') if (joined.startsWith('WF_ERR::')) { const parts = joined.split('::') - throw new Error(`elevated helper failed: errNum=${parts[1] || 'unknown'}, errMsg=${parts[2] || 'unknown'}, partial=${parts.slice(3).join('::') || '(empty)'}`) + const errNum = parts[1] || 'unknown' + const errMsg = parts[2] || 'unknown' + const partial = parts.slice(3).join('::') + throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`) } const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined - const payloads = normalizedOutput.match(/\{[^{}]*\}/g) ?? [] - for (const item of payloads) { - try { - const parsed = JSON.parse(item) - if (parsed?.success === true && typeof parsed?.key === 'string') { - return parsed.key - } - if (typeof parsed?.result === 'string') { - return parsed.result - } - } catch { - // ignore + const extractJsonObjects = (s: string): any[] => { + const results: any[] = [] + const re = /\{[^{}]*\}/g + let m: RegExpExecArray | null + while ((m = re.exec(s)) !== null) { + try { results.push(JSON.parse(m[0])) } catch { } } + return results } - - throw new Error(`elevated helper returned invalid output: ${normalizedOutput}`) + const allJson = extractJsonObjects(normalizedOutput) + const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string') + if (successPayload) return successPayload.key + const resultPayload = allJson.find(p => typeof p?.result === 'string') + if (resultPayload) return resultPayload.result + throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1]) } private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } { diff --git a/package-lock.json b/package-lock.json index f94f345..a6d5a0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,7 @@ "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", "adm-zip": "^0.5.16", - "electron": "^39.2.7", + "electron": "^39.6.0", "electron-builder": "^25.1.8", "sass": "^1.83.0", "sharp": "^0.34.5", diff --git a/package.json b/package.json index 7004a55..08d1b0b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "main": "dist-electron/main.js", "scripts": { "dev": "vite", + "icon:mac": "bash scripts/build-macos-icon.sh", "prebuild": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js", "build": "tsc && vite build && electron-builder && node scripts/add-size-to-yml.js", "build:ci": "node scripts/prepare-release-announcement.js && tsc && vite build && electron-builder --publish never && node scripts/add-size-to-yml.js", @@ -24,9 +25,9 @@ "postinstall": "electron-rebuild" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@modelcontextprotocol/sdk": "^1.27.1", "@mui/material": "^7.3.9", "@types/dompurify": "^3.0.5", "@types/marked": "^5.0.2", @@ -71,7 +72,7 @@ "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", "adm-zip": "^0.5.16", - "electron": "^39.2.7", + "electron": "^39.6.0", "electron-builder": "^25.1.8", "sass": "^1.83.0", "sharp": "^0.34.5", @@ -101,6 +102,7 @@ "requestedExecutionLevel": "asInvoker" }, "mac": { + "icon": "public/icon.icns", "category": "public.app-category.utilities", "hardenedRuntime": true, "gatekeeperAssess": false, diff --git a/public/icon-dock.png b/public/icon-dock.png new file mode 100644 index 0000000..000ff1e Binary files /dev/null and b/public/icon-dock.png differ diff --git a/public/icon.icns b/public/icon.icns new file mode 100644 index 0000000..db1e6a6 Binary files /dev/null and b/public/icon.icns differ diff --git a/public/tray-mac.png b/public/tray-mac.png new file mode 100644 index 0000000..d063b34 Binary files /dev/null and b/public/tray-mac.png differ diff --git a/public/xinnian-dock.png b/public/xinnian-dock.png new file mode 100644 index 0000000..e35a673 Binary files /dev/null and b/public/xinnian-dock.png differ diff --git a/public/xinnian-tray.png b/public/xinnian-tray.png new file mode 100644 index 0000000..c022ba0 Binary files /dev/null and b/public/xinnian-tray.png differ diff --git a/public/xinnian.icns b/public/xinnian.icns new file mode 100644 index 0000000..8e9cdb1 Binary files /dev/null and b/public/xinnian.icns differ diff --git a/resources/macos/image_scan_helper b/resources/macos/image_scan_helper new file mode 100755 index 0000000..04678c0 Binary files /dev/null and b/resources/macos/image_scan_helper differ diff --git a/resources/macos/libdobby.dylib b/resources/macos/libdobby.dylib new file mode 100755 index 0000000..6b8a60a Binary files /dev/null and b/resources/macos/libdobby.dylib differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib new file mode 100755 index 0000000..6427b64 Binary files /dev/null and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/macos/libwcdb_decrypt.dylib b/resources/macos/libwcdb_decrypt.dylib new file mode 100755 index 0000000..7b5ba4b Binary files /dev/null and b/resources/macos/libwcdb_decrypt.dylib differ diff --git a/resources/macos/libwx_key.dylib b/resources/macos/libwx_key.dylib new file mode 100755 index 0000000..59c673a Binary files /dev/null and b/resources/macos/libwx_key.dylib differ diff --git a/resources/macos/xkey_helper b/resources/macos/xkey_helper new file mode 100755 index 0000000..1c9b951 Binary files /dev/null and b/resources/macos/xkey_helper differ diff --git a/scripts/build-macos-icon.sh b/scripts/build-macos-icon.sh new file mode 100644 index 0000000..3b79b73 --- /dev/null +++ b/scripts/build-macos-icon.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC_PNG="${1:-${ROOT_DIR}/public/logo.png}" +OUT_ICNS="${2:-${ROOT_DIR}/public/icon.icns}" +OUT_DOCK_PNG="${3:-${ROOT_DIR}/public/icon-dock.png}" +OUT_TRAY_PNG="${4:-${ROOT_DIR}/public/tray-mac.png}" +ICONSET_DIR="${ROOT_DIR}/public/icon.iconset" +TMP_DIR="$(mktemp -d)" +PADDED_MASTER="${TMP_DIR}/padded-master.png" +INNER_SIZE="${INNER_SIZE:-824}" +TRAY_INNER_SIZE="${TRAY_INNER_SIZE:-44}" + +if [[ ! -f "${SRC_PNG}" ]]; then + echo "source png not found: ${SRC_PNG}" >&2 + exit 1 +fi + +cleanup() { + rm -rf "${ICONSET_DIR}" "${TMP_DIR}" +} + +trap cleanup EXIT + +rm -rf "${ICONSET_DIR}" +mkdir -p "${ICONSET_DIR}" + +sips -z "${INNER_SIZE}" "${INNER_SIZE}" "${SRC_PNG}" --out "${PADDED_MASTER}" >/dev/null +sips -p 1024 1024 "${PADDED_MASTER}" --out "${OUT_DOCK_PNG}" >/dev/null + +TRAY_MASTER="${TMP_DIR}/tray-master.png" +sips -z "${TRAY_INNER_SIZE}" "${TRAY_INNER_SIZE}" "${SRC_PNG}" --out "${TRAY_MASTER}" >/dev/null +sips -p 64 64 "${TRAY_MASTER}" --out "${OUT_TRAY_PNG}" >/dev/null + +for size in 16 32 128 256 512; do + sips -z "${size}" "${size}" "${OUT_DOCK_PNG}" --out "${ICONSET_DIR}/icon_${size}x${size}.png" >/dev/null + retina_size=$((size * 2)) + sips -z "${retina_size}" "${retina_size}" "${OUT_DOCK_PNG}" --out "${ICONSET_DIR}/icon_${size}x${size}@2x.png" >/dev/null +done + +iconutil -c icns "${ICONSET_DIR}" -o "${OUT_ICNS}" + +echo "generated ${OUT_ICNS}" +echo "generated ${OUT_DOCK_PNG}" +echo "generated ${OUT_TRAY_PNG}" diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 925833c..d368b26 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -691,13 +691,34 @@ function SettingsPage() { setKeyStatus(status) }) - const result = await window.electronAPI.wxKey.startGetKey() + const result = await window.electronAPI.wxKey.startGetKey(undefined, dbPath || undefined) removeListener() if (result.success && result.key) { setDecryptKey(result.key) await configService.setDecryptKey(result.key) + if (dbPath) { + const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, result.key) + if (resolved.success && resolved.wxid) { + setWxid(resolved.wxid) + setIsAccountVerified(true) + await configService.setMyWxid(resolved.wxid) + showMessage(`密钥获取成功!已验证账号: ${resolved.wxid}`, true) + setKeyStatus('') + return + } + } + + if (result.validatedWxid) { + setWxid(result.validatedWxid) + setIsAccountVerified(true) + await configService.setMyWxid(result.validatedWxid) + showMessage(`密钥获取成功!已验证账号: ${result.validatedWxid}`, true) + setKeyStatus('') + return + } + setKeyStatus('正在检测当前登录账号...') let accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10) @@ -771,7 +792,7 @@ function SettingsPage() { }) setKeyStatus('Hook 已安装,请登录微信...') - const result = await window.electronAPI.wxKey.startGetKey() + const result = await window.electronAPI.wxKey.startGetKey(undefined, dbPath || undefined) removeListener() if (result.success && result.key) { @@ -893,10 +914,36 @@ function SettingsPage() { setWxidOptions([]) setShowWxidDropdown(false) } else { - // 多个账号,显示选择下拉框 + let selectedWxid = '' + + if (decryptKey.length === 64) { + const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, decryptKey) + if (resolved.success && resolved.wxid && wxids.includes(resolved.wxid)) { + selectedWxid = resolved.wxid + setWxid(selectedWxid) + } + } + + if (!selectedWxid) { + let accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10) + if (!accountInfo) { + accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60) + } + + if (accountInfo && wxids.includes(accountInfo.wxid)) { + selectedWxid = accountInfo.wxid + setWxid(selectedWxid) + } + } + setWxidOptions(wxids) setShowWxidDropdown(true) - showMessage(`检测到 ${wxids.length} 个候选账号目录,请选择后验证`, true) + showMessage( + selectedWxid + ? `检测到 ${wxids.length} 个候选账号目录,已按最新活动优先选择:${selectedWxid}` + : `检测到 ${wxids.length} 个候选账号目录,请选择后验证`, + true + ) } } catch (e) { showMessage(`扫描失败: ${e}`, false) diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss index 00de893..25e3423 100644 --- a/src/pages/WelcomePage.scss +++ b/src/pages/WelcomePage.scss @@ -25,7 +25,7 @@ .welcome-page.is-standalone .window-controls { position: absolute; top: 12px; - right: 18px; + left: 18px; display: inline-flex; gap: 8px; padding: 6px; @@ -306,6 +306,7 @@ gap: 14px; min-height: 0; overflow-y: auto; + overflow-x: hidden; /* 自定义滚动条 */ &::-webkit-scrollbar { @@ -398,6 +399,7 @@ .info-content { margin-top: 6px; + min-width: 0; } .info-content h3 { @@ -417,6 +419,7 @@ margin: 0; padding-left: 20px; list-style: none; + min-width: 0; } .info-list li { @@ -425,6 +428,9 @@ color: var(--text-secondary); line-height: 1.8; padding-left: 8px; + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; } .info-list li::before { @@ -435,12 +441,16 @@ } .info-list code { + display: inline; padding: 2px 6px; border-radius: 4px; background: rgba(139, 115, 85, 0.1); color: var(--primary); font-size: 12px; font-family: 'Consolas', monospace; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; } .info-tips { diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 69529bc..48e83e9 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -44,6 +44,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const [error, setError] = useState('') const [isScanningWxid, setIsScanningWxid] = useState(false) + const [isDetectingPath, setIsDetectingPath] = useState(false) const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [showDecryptKey, setShowDecryptKey] = useState(false) @@ -199,6 +200,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}` const showWindowControls = standalone + useEffect(() => { + if (currentStep.id !== 'db') return + if (dbPath) return + if (isDetectingPath) return + + void handleAutoDetectPath(true) + }, [currentStep.id, dbPath, isDetectingPath]) + const handleMinimize = () => { window.electronAPI.window.minimize() } @@ -234,6 +243,48 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { } } + const handleAutoDetectPath = async (silent = false) => { + if (isDetectingPath) return + + setIsDetectingPath(true) + if (!silent) setError('') + + try { + const result = await window.electronAPI.dbPath.autoDetect() + if (result.success && result.path) { + setDbPath(result.path) + setError('') + return + } + + if (!silent) { + setError(result.error || '未能自动检测到微信数据库目录') + } + } catch (e) { + if (!silent) { + setError(`自动检测失败: ${e}`) + } + } finally { + setIsDetectingPath(false) + } + } + + const handleOpenDetectedPath = async () => { + if (!dbPath) { + setError('当前没有可打开的数据库目录') + return + } + + try { + const result = await window.electronAPI.shell.openPath(dbPath) + if (result) { + setError(result) + } + } catch (e) { + setError(`打开目录失败: ${e}`) + } + } + const handleSelectCachePath = async () => { @@ -265,11 +316,38 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setWxidOptions(wxids) setIsAccountVerified(false) if (wxids.length > 0) { - // 密钥前仅做候选识别,默认优先 wxid_ 前缀目录 - const wxidAccount = wxids.find(id => id.startsWith('wxid_')) - const selectedWxid = wxidAccount || wxids[0] - setWxid(selectedWxid) - if (!silent) setError('') + let selectedWxid = '' + + if (decryptKey.length === 64) { + const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, decryptKey) + if (resolved.success && resolved.wxid && wxids.includes(resolved.wxid)) { + selectedWxid = resolved.wxid + } + } + + if (!selectedWxid) { + let accountInfo: { wxid: string; dbPath: string } | null = null + accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10) + if (!accountInfo) { + accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60) + } + + if (accountInfo && wxids.includes(accountInfo.wxid)) { + selectedWxid = accountInfo.wxid + } + } + + if (!selectedWxid) { + const wxidAccount = wxids.find(id => id.startsWith('wxid_')) + selectedWxid = wxidAccount || wxids[0] + } + + if (selectedWxid) { + setWxid(selectedWxid) + if (!silent) setError('') + } else { + if (!silent) setError('未能自动确定正确账号目录,请手动选择') + } } else { if (!silent) setError('未检测到账号目录,请检查路径') } @@ -288,13 +366,29 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setError('') setDbKeyStatus('正在准备获取密钥...') try { - const result = await window.electronAPI.wxKey.startGetKey(wechatPath) + const result = await window.electronAPI.wxKey.startGetKey(wechatPath, dbPath || undefined) if (result.success && result.key) { setDecryptKey(result.key) setDbKeyStatus('密钥获取成功,正在验证账号目录...') setError('') setShowWechatPathPrompt(false) + if (dbPath) { + const resolved = await window.electronAPI.wcdb.resolveValidWxid(dbPath, result.key) + if (resolved.success && resolved.wxid) { + setWxid(resolved.wxid) + setIsAccountVerified(true) + setDbKeyStatus(`密钥获取成功,已验证账号目录: ${resolved.wxid}`) + return + } + } + + if (result.validatedWxid) { + setWxid(result.validatedWxid) + setDbKeyStatus(`密钥获取成功,已验证账号目录: ${result.validatedWxid}`) + return + } + // 先尝试当前登录账号检测(强信号) let accountInfo: { wxid: string; dbPath: string } | null = null if (dbPath) { @@ -593,12 +687,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{showWindowControls && (
- +
)} @@ -678,20 +772,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {currentStep.id === 'db' && (
-

数据库目录说明

-

这是微信存储聊天记录的根目录,通常位于:

+

自动获取数据库目录

+

系统会优先自动识别当前设备上的微信数据存储目录。

    - {isMac ? ( -
  • ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/<version> 或旧版 xwechat_files
  • - ) : ( -
  • 微信 → 设置 → 账号与存储 → 存储位置
  • - )} - {isMac ? ( -
  • 支持 4.0.5+ 新路径和旧版 xwechat_files 路径
  • - ) : ( -
  • 按照上面的路径找到 xwechat_files 目录
  • - )} -
  • {isMac ? '建议优先选择版本目录或账号根目录' : '路径中不能包含中文字符'}
  • +
  • 进入本步骤后会先尝试自动检测
  • +
  • 检测到结果后可直接打开文件夹确认
  • +
  • {isMac ? '若未命中,再手动选择版本目录或账号目录' : '若未命中,再按微信存储位置手动选择'}
{!isMac && (
@@ -718,15 +804,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {currentStep.id === 'key' && (

解密密钥说明

-

用于解密微信数据库的64位十六进制密钥。

+

此步骤会在本机完成密钥识别与账号校验。

    -
  • {isMac ? 'macOS 通过 helper + 断点捕获获取 DbKey' : '点击"自动获取"会自动启动微信'}
  • -
  • {isMac ? '此流程要求先关闭 SIP,并允许管理员提权' : '等待提示"hook安装成功"后登录'}
  • -
  • {isMac ? '成功后会自动回填 64 位 DbKey 并尝试识别账号' : '登录后会自动识别账号'}
  • +
  • {isMac ? '建议先启动微信,并按界面提示完成授权' : '点击“自动获取”后按提示操作'}
  • +
  • {isMac ? '识别完成后会自动尝试匹配账号目录' : '完成后会自动识别账号目录'}
  • +
  • 密钥仅保存在本地配置中
- {isMac ? '若 SIP 未关闭,自动获取会直接失败并给出提示' : '密钥仅保存在本地,不会上传'} + {isMac ? '若系统环境不满足要求,界面会直接给出提示' : '密钥不会上传到服务器'}
)} @@ -822,9 +908,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { value={dbPath} onChange={(e) => setDbPath(e.target.value)} /> - +
+ + +
+ {dbPath && ( +
+ +
+ )}
{isMac ? '请选择微信版本目录或账号根目录' : '请选择微信-设置-存储位置对应的目录'}
{!isMac && (
⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index fd332ae..1e60d6e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -294,7 +294,7 @@ export interface ElectronAPI { killWeChat: () => Promise launchWeChat: () => Promise waitForWindow: (maxWaitSeconds?: number) => Promise - startGetKey: (customWechatPath?: string) => Promise<{ success: boolean; key?: string; error?: string; needManualPath?: boolean }> + startGetKey: (customWechatPath?: string, dbPath?: string) => Promise<{ success: boolean; key?: string; error?: string; needManualPath?: boolean; validatedWxid?: string }> cancel: () => Promise detectCurrentAccount: (dbPath?: string, maxTimeDiffMinutes?: number) => Promise<{ wxid: string; dbPath: string } | null> onStatus: (callback: (data: { status: string; level: number }) => void) => () => void @@ -307,6 +307,7 @@ export interface ElectronAPI { } wcdb: { testConnection: (dbPath: string, hexKey: string, wxid: string, isAutoConnect?: boolean) => Promise<{ success: boolean; error?: string; sessionCount?: number }> + resolveValidWxid: (dbPath: string, hexKey: string) => Promise<{ success: boolean; wxid?: string; error?: string }> open: (dbPath: string, hexKey: string, wxid: string) => Promise close: () => Promise decryptDatabase: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; totalFiles?: number; successCount?: number; failCount?: number }>