diff --git a/electron/entitlements.mac.plist b/electron/entitlements.mac.plist new file mode 100644 index 0000000..02af842 --- /dev/null +++ b/electron/entitlements.mac.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.debugger + + com.apple.security.get-task-allow + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/electron/main.ts b/electron/main.ts index f4ccb98..ddac8ec 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,7 @@ import { groupAnalyticsService } from './services/groupAnalyticsService' import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { KeyService } from './services/keyService' +import { KeyServiceMac } from './services/keyServiceMac' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' import { snsService, isVideoUrl } from './services/snsService' @@ -88,7 +89,9 @@ let onboardingWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null const sessionChatWindows = new Map() const sessionChatWindowSources = new Map() -const keyService = new KeyService() +const keyService = process.platform === 'darwin' + ? new KeyServiceMac() as any + : new KeyService() let mainWindowReady = false let shouldShowMain = true diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts index 9b29ab3..89edc29 100644 --- a/electron/services/cloudControlService.ts +++ b/electron/services/cloudControlService.ts @@ -66,6 +66,10 @@ class CloudControlService { return `Windows ${release}` } + if (platform === 'darwin') { + return `macOS ${os.release()}` + } + return platform } diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index ee15b02..b199b85 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -16,8 +16,13 @@ export class DbPathService { const possiblePaths: string[] = [] const home = homedir() - // 微信4.x 数据目录 - possiblePaths.push(join(home, 'Documents', 'xwechat_files')) + // macOS 微信路径(固定) + if (process.platform === 'darwin') { + possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')) + } else { + // Windows 微信4.x 数据目录 + possiblePaths.push(join(home, 'Documents', 'xwechat_files')) + } for (const path of possiblePaths) { @@ -193,6 +198,9 @@ export class DbPathService { */ getDefaultPath(): string { const home = homedir() + if (process.platform === 'darwin') { + return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files') + } return join(home, 'Documents', 'xwechat_files') } } diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index d9d607b..2caa66b 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -12,6 +12,7 @@ type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: stri type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } export class KeyService { + private readonly isMac = process.platform === 'darwin' private koffi: any = null private lib: any = null private initialized = false diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts new file mode 100644 index 0000000..764337f --- /dev/null +++ b/electron/services/keyServiceMac.ts @@ -0,0 +1,485 @@ +import { app, shell } from 'electron' +import { join } from 'path' +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { execFile, spawn } from 'child_process' +import { promisify } from 'util' + +type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } +const execFileAsync = promisify(execFile) + +export class KeyServiceMac { + private koffi: any = null + private lib: any = null + private initialized = false + + private GetDbKey: any = null + private ScanMemoryForImageKey: any = null + private FreeString: any = null + private ListWeChatProcesses: any = null + + private getHelperPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + + if (process.env.WX_KEY_HELPER_PATH) { + candidates.push(process.env.WX_KEY_HELPER_PATH) + } + + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper')) + candidates.push(join(process.resourcesPath, 'xkey_helper')) + } else { + const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'xkey_helper')) + candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper')) + } + + for (const path of candidates) { + if (existsSync(path)) return path + } + + throw new Error('xkey_helper not found') + } + + private getDylibPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + + if (process.env.WX_KEY_DYLIB_PATH) { + candidates.push(process.env.WX_KEY_DYLIB_PATH) + } + + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib')) + candidates.push(join(process.resourcesPath, 'libwx_key.dylib')) + } else { + const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'libwx_key.dylib')) + candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib')) + } + + for (const path of candidates) { + if (existsSync(path)) return path + } + + throw new Error('libwx_key.dylib not found') + } + + async initialize(): Promise { + if (this.initialized) return + + try { + this.koffi = require('koffi') + const dylibPath = this.getDylibPath() + + if (!existsSync(dylibPath)) { + throw new Error('libwx_key.dylib not found: ' + dylibPath) + } + + this.lib = this.koffi.load(dylibPath) + + this.GetDbKey = this.lib.func('const char* GetDbKey()') + this.ScanMemoryForImageKey = this.lib.func('const char* ScanMemoryForImageKey(int pid, const char* ciphertext)') + this.FreeString = this.lib.func('void FreeString(const char* str)') + this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()') + + this.initialized = true + } catch (e: any) { + throw new Error('Failed to initialize KeyServiceMac: ' + e.message) + } + } + + async autoGetDbKey( + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void + ): Promise { + try { + onStatus?.('正在获取数据库密钥...', 0) + let parsed = await this.getDbKeyParsed(timeoutMs, onStatus) + console.log('[KeyServiceMac] GetDbKey returned:', parsed.raw) + + // ATTACH_FAILED 时自动走图形化授权,再重试一次 + if (!parsed.success && parsed.code === 'ATTACH_FAILED') { + onStatus?.('检测到调试权限不足,正在请求系统授权...', 0) + const permissionOk = await this.enableDebugPermissionWithPrompt() + if (permissionOk) { + onStatus?.('授权完成,正在重试获取密钥...', 0) + parsed = await this.getDbKeyParsed(timeoutMs, onStatus) + console.log('[KeyServiceMac] GetDbKey retry returned:', parsed.raw) + } else { + onStatus?.('已取消系统授权', 2) + return { success: false, error: '已取消系统授权' } + } + } + + if (!parsed.success && parsed.code === 'ATTACH_FAILED') { + // DevToolsSecurity 仍不足时,自动拉起开发者工具权限页面 + await this.openDeveloperToolsPrivacySettings() + await this.revealCurrentExecutableInFinder() + const msg = `无法附加到微信进程。已打开“开发者工具”设置,并在访达中定位当前运行程序。\n请在“隐私与安全性 -> 开发者工具”点击“+”添加并允许:${process.execPath}` + onStatus?.(msg, 2) + return { success: false, error: msg } + } + + if (!parsed.success) { + const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail) + onStatus?.(errorMsg, 2) + return { success: false, error: errorMsg } + } + + onStatus?.('密钥获取成功', 1) + return { success: true, key: parsed.key } + } catch (e: any) { + console.error('[KeyServiceMac] Error:', e) + console.error('[KeyServiceMac] Stack:', e.stack) + onStatus?.('获取失败: ' + e.message, 2) + return { success: false, error: e.message } + } + } + + private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } { + const text = typeof raw === 'string' ? raw : '' + if (!text) return { success: false, code: 'UNKNOWN', raw: text } + if (!text.startsWith('ERROR:')) return { success: true, key: text, raw: text } + + const parts = text.split(':') + return { + success: false, + code: parts[1] || 'UNKNOWN', + detail: parts.slice(2).join(':') || undefined, + raw: text + } + } + + private async getDbKeyParsed( + timeoutMs: number, + onStatus?: (message: string, level: number) => void + ): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> { + try { + const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus) + return this.parseDbKeyResult(helperResult) + } catch (e: any) { + console.warn('[KeyServiceMac] helper unavailable, fallback to dylib:', e?.message || e) + if (!this.initialized) { + await this.initialize() + } + return this.parseDbKeyResult(this.GetDbKey()) + } + } + + private async getDbKeyByHelper( + timeoutMs: number, + onStatus?: (message: string, level: number) => void + ): Promise { + const helperPath = this.getHelperPath() + const waitMs = Math.max(timeoutMs, 30_000) + return await new Promise((resolve, reject) => { + const child = spawn(helperPath, [String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + let stdoutBuf = '' + let stderrBuf = '' + let settled = false + let killTimer: ReturnType | null = null + let pidNotified = false + let locatedNotified = false + let hookNotified = false + + const done = (fn: () => void) => { + if (settled) return + settled = true + if (killTimer) clearTimeout(killTimer) + fn() + } + + const processHelperLine = (line: string) => { + if (!line) return + console.log('[KeyServiceMac][helper][stderr]', line) + const pidMatch = line.match(/Selected PID=(\d+)/) + if (pidMatch && !pidNotified) { + pidNotified = true + onStatus?.(`已找到微信进程 PID=${pidMatch[1]},正在定位目标函数...`, 0) + } + if (!locatedNotified && (line.includes('strict hit=') || line.includes('sink matched by strict semantic signature'))) { + locatedNotified = true + onStatus?.('已定位到目标函数,正在安装 Hook...', 0) + } + if (line.includes('hook installed @')) { + hookNotified = true + onStatus?.('Hook 已安装,等待微信触发密钥调用...', 0) + } + if (line.includes('[MASTER] hex64=')) { + onStatus?.('检测到密钥回调,正在回填...', 0) + } + } + + child.stdout.on('data', (chunk: Buffer | string) => { + const data = chunk.toString() + stdout += data + stdoutBuf += data + const parts = stdoutBuf.split(/\r?\n/) + stdoutBuf = parts.pop() || '' + }) + + child.stderr.on('data', (chunk: Buffer | string) => { + const data = chunk.toString() + stderr += data + stderrBuf += data + const parts = stderrBuf.split(/\r?\n/) + stderrBuf = parts.pop() || '' + for (const line of parts) processHelperLine(line.trim()) + }) + + child.on('error', (err) => { + done(() => reject(err)) + }) + + child.on('close', () => { + if (stderrBuf.trim()) processHelperLine(stderrBuf.trim()) + + const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean) + const last = lines[lines.length - 1] + if (!last) { + done(() => reject(new Error(stderr.trim() || 'helper returned empty output'))) + return + } + + let payload: any + try { + payload = JSON.parse(last) + } catch { + done(() => reject(new Error('helper returned invalid json: ' + last))) + return + } + + if (payload?.success === true && typeof payload?.key === 'string') { + if (!hookNotified) { + onStatus?.('Hook 已触发,正在回填密钥...', 0) + } + done(() => resolve(payload.key)) + return + } + if (typeof payload?.result === 'string') { + done(() => resolve(payload.result)) + return + } + done(() => reject(new Error('helper json missing key/result'))) + }) + + killTimer = setTimeout(() => { + try { child.kill('SIGTERM') } catch { } + done(() => reject(new Error(`helper timeout after ${waitMs}ms`))) + }, waitMs + 10_000) + }) + } + + private mapDbKeyErrorMessage(code?: string, detail?: string): string { + if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行' + if (code === 'ATTACH_FAILED') { + const isDevElectron = process.execPath.includes('/node_modules/electron/') + if ((detail || '').includes('task_for_pid:5')) { + if (isDevElectron) { + return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。` + } + return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。' + } + return `无法附加到进程 (${detail || ''})` + } + if (code === 'FRIDA_FAILED') { + if ((detail || '').includes('FRIDA_TIMEOUT')) { + return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。' + } + return `Frida 语义定位失败 (${detail || ''})` + } + if (code === 'HOOK_FAILED') { + if ((detail || '').includes('HOOK_TIMEOUT')) { + return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。' + } + if ((detail || '').includes('attach_wait_timeout')) { + return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。' + } + return `原生 Hook 失败 (${detail || ''})` + } + if (code === 'HOOK_TARGET_ONLY') { + return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。` + } + if (code === 'SCAN_FAILED') return '内存扫描失败' + return '未知错误' + } + + private async enableDebugPermissionWithPrompt(): Promise { + const script = [ + 'do shell script "/usr/sbin/DevToolsSecurity -enable" with administrator privileges' + ] + + try { + await execFileAsync('osascript', script.flatMap(line => ['-e', line]), { + timeout: 30_000 + }) + return true + } catch (e: any) { + const msg = `${e?.stderr || ''}\n${e?.message || ''}` + const cancelled = msg.includes('User canceled') || msg.includes('(-128)') + if (!cancelled) { + console.error('[KeyServiceMac] enableDebugPermissionWithPrompt failed:', msg) + } + return false + } + } + + private async openDeveloperToolsPrivacySettings(): Promise { + const url = 'x-apple.systempreferences:com.apple.preference.security?Privacy_DevTools' + try { + await shell.openExternal(url) + } catch (e) { + console.error('[KeyServiceMac] Failed to open settings page:', e) + } + } + + private async revealCurrentExecutableInFinder(): Promise { + try { + shell.showItemInFolder(process.execPath) + } catch (e) { + console.error('[KeyServiceMac] Failed to reveal executable in Finder:', e) + } + } + + async autoGetImageKey( + accountPath?: string, + onStatus?: (message: string) => void, + wxid?: string + ): Promise { + onStatus?.('macOS 请使用内存扫描方式') + return { success: false, error: 'macOS 请使用内存扫描方式' } + } + + async autoGetImageKeyByMemoryScan( + userDir: string, + onProgress?: (message: string) => void + ): Promise { + if (!this.initialized) { + await this.initialize() + } + + try { + // 1. 查找模板文件获取密文和 XOR 密钥 + onProgress?.('正在查找模板文件...') + let result = await this._findTemplateData(userDir, 32) + let { ciphertext, xorKey } = result + + if (ciphertext && xorKey === null) { + onProgress?.('未找到有效密钥,尝试扫描更多文件...') + result = await this._findTemplateData(userDir, 100) + xorKey = result.xorKey + } + + if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' } + + onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) + + // 2. 找微信 PID + const pid = await this.findWeChatPid() + if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } + + onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) + + // 3. 持续轮询内存扫描 + const deadline = Date.now() + 60_000 + let scanCount = 0 + while (Date.now() < deadline) { + scanCount++ + onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) + const aesKey = await this._scanMemoryForAesKey(pid, ciphertext) + if (aesKey) { + onProgress?.('密钥获取成功') + return { success: true, xorKey, aesKey } + } + await new Promise(r => setTimeout(r, 5000)) + } + + return { success: false, error: '60 秒内未找到 AES 密钥' } + } catch (e: any) { + return { success: false, error: `内存扫描失败: ${e.message}` } + } + } + + private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + + const collect = (dir: string, results: string[], maxFiles: number) => { + if (results.length >= maxFiles) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (results.length >= maxFiles) break + const full = join(dir, entry.name) + if (entry.isDirectory()) collect(full, results, maxFiles) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) + } + } catch { } + } + + const files: string[] = [] + collect(userDir, files, limit) + + files.sort((a, b) => { + try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } + }) + + let ciphertext: Buffer | null = null + const tailCounts: Record = {} + + for (const f of files.slice(0, 32)) { + try { + const data = readFileSync(f) + if (data.length < 8) continue + + if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } + + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { + ciphertext = data.subarray(0xF, 0x1F) + } + } catch { } + } + + let xorKey: number | null = null + let maxCount = 0 + for (const [key, count] of Object.entries(tailCounts)) { + if (count > maxCount) { + maxCount = count + const [x, y] = key.split('_').map(Number) + const k = x ^ 0xFF + if (k === (y ^ 0xD9)) xorKey = k + } + } + + return { ciphertext, xorKey } + } + + private async _scanMemoryForAesKey(pid: number, ciphertext: Buffer): Promise { + const ciphertextHex = ciphertext.toString('hex') + const aesKey = this.ScanMemoryForImageKey(pid, ciphertextHex) + return aesKey || null + } + + private async findWeChatPid(): Promise { + const { execSync } = await import('child_process') + try { + const output = execSync('pgrep -x WeChat', { encoding: 'utf8' }) + const pid = parseInt(output.trim()) + return isNaN(pid) ? null : pid + } catch { + return null + } + } + + cleanup(): void { + this.lib = null + this.initialized = false + } +} diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index d7acf27..ed0862a 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1,5 +1,6 @@ import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' +import { tmpdir } from 'os' // DLL 初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null @@ -60,6 +61,7 @@ export class WcdbCore { private currentPath: string | null = null private currentKey: string | null = null private currentWxid: string | null = null + private currentDbStoragePath: string | null = null // 函数引用 private wcdbInitProtection: any = null @@ -128,14 +130,17 @@ export class WcdbCore { private readonly avatarCacheTtlMs = 10 * 60 * 1000 private logTimer: NodeJS.Timeout | null = null private lastLogTail: string | null = null + private lastResolvedLogPath: string | null = null setPaths(resourcesPath: string, userDataPath: string): void { this.resourcesPath = resourcesPath this.userDataPath = userDataPath + this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true) } setLogEnabled(enabled: boolean): void { this.logEnabled = enabled + this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true) if (this.isLogEnabled() && this.initialized) { this.startLogPolling() } else { @@ -143,8 +148,13 @@ export class WcdbCore { } } - // 使用命名管道 IPC + // 使用命名管道 IPC (仅 Windows) startMonitor(callback: (type: string, json: string) => void): boolean { + if (process.platform !== 'win32') { + console.warn('[wcdbCore] Monitor not supported on macOS') + return false + } + if (!this.wcdbStartMonitorPipe) { return false } @@ -251,9 +261,13 @@ export class WcdbCore { /** - * 获取 DLL 路径 + * 获取库文件路径(跨平台) */ private getDllPath(): string { + const isMac = process.platform === 'darwin' + const libName = isMac ? 'libwcdb_api.dylib' : 'wcdb_api.dll' + const subDir = isMac ? 'macos' : '' + const envDllPath = process.env.WCDB_DLL_PATH if (envDllPath && envDllPath.length > 0) { return envDllPath @@ -265,22 +279,22 @@ export class WcdbCore { const candidates = [ // 环境变量指定 resource 目录 - process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, 'wcdb_api.dll') : null, + process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null, // 显式 setPaths 设置的路径 - this.resourcesPath ? join(this.resourcesPath, 'wcdb_api.dll') : null, - // text/resources/wcdb_api.dll (打包常见结构) - join(resourcesPath, 'resources', 'wcdb_api.dll'), - // items/resourcesPath/wcdb_api.dll (扁平结构) - join(resourcesPath, 'wcdb_api.dll'), + this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null, + // resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll + join(resourcesPath, 'resources', subDir, libName), + // resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构) + join(resourcesPath, subDir, libName), // CWD fallback - join(process.cwd(), 'resources', 'wcdb_api.dll') + join(process.cwd(), 'resources', subDir, libName) ].filter(Boolean) as string[] for (const path of candidates) { if (existsSync(path)) return path } - return candidates[0] || 'wcdb_api.dll' + return candidates[0] || libName } private isLogEnabled(): boolean { @@ -292,14 +306,97 @@ export class WcdbCore { private writeLog(message: string, force = false): void { if (!force && !this.isLogEnabled()) return const line = `[${new Date().toISOString()}] ${message}` - // 同时输出到控制台和文件 + const candidates: string[] = [] + if (this.userDataPath) candidates.push(join(this.userDataPath, 'logs', 'wcdb.log')) + if (process.env.WCDB_LOG_DIR) candidates.push(join(process.env.WCDB_LOG_DIR, 'logs', 'wcdb.log')) + candidates.push(join(process.cwd(), 'logs', 'wcdb.log')) + candidates.push(join(tmpdir(), 'weflow-wcdb.log')) + + const uniq = Array.from(new Set(candidates)) + for (const filePath of uniq) { + try { + const dir = dirname(filePath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + appendFileSync(filePath, line + '\n', { encoding: 'utf8' }) + this.lastResolvedLogPath = filePath + return + } catch (e) { + console.error(`[wcdbCore] writeLog failed path=${filePath}:`, e) + } + } + + console.error('[wcdbCore] writeLog failed for all candidates:', uniq.join(' | ')) + } + + private formatSqlForLog(sql: string, maxLen = 240): string { + const compact = String(sql || '').replace(/\s+/g, ' ').trim() + if (compact.length <= maxLen) return compact + return compact.slice(0, maxLen) + '...' + } + + private async dumpDbStatus(tag: string): Promise { try { - const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() - const dir = join(base, 'logs') - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' }) - } catch { } + if (!this.ensureReady()) { + this.writeLog(`[diag:${tag}] db_status skipped: not connected`, true) + return + } + if (!this.wcdbGetDbStatus) { + this.writeLog(`[diag:${tag}] db_status skipped: api not supported`, true) + return + } + const outPtr = [null as any] + const rc = this.wcdbGetDbStatus(this.handle, outPtr) + if (rc !== 0 || !outPtr[0]) { + this.writeLog(`[diag:${tag}] db_status failed rc=${rc} outPtr=${outPtr[0] ? 'set' : 'null'}`, true) + return + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) { + this.writeLog(`[diag:${tag}] db_status decode failed`, true) + return + } + this.writeLog(`[diag:${tag}] db_status=${jsonStr}`, true) + } catch (e) { + this.writeLog(`[diag:${tag}] db_status exception: ${String(e)}`, true) + } + } + + private async runPostOpenDiagnostics(dbPath: string, dbStoragePath: string | null, sessionDbPath: string | null, wxid: string): Promise { + try { + this.writeLog(`[diag:open] input dbPath=${dbPath} wxid=${wxid}`, true) + this.writeLog(`[diag:open] resolved dbStorage=${dbStoragePath || 'null'}`, true) + this.writeLog(`[diag:open] resolved sessionDb=${sessionDbPath || 'null'}`, true) + if (!dbStoragePath) return + try { + const entries = readdirSync(dbStoragePath) + const sample = entries.slice(0, 20).join(',') + this.writeLog(`[diag:open] dbStorage entries(${entries.length}) sample=${sample}`, true) + } catch (e) { + this.writeLog(`[diag:open] list dbStorage failed: ${String(e)}`, true) + } + + const contactProbe = await this.execQuery( + 'contact', + null, + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name LIMIT 50" + ) + if (contactProbe.success) { + const names = (contactProbe.rows || []).map((r: any) => String(r?.name || '')).filter(Boolean) + this.writeLog(`[diag:open] contact sqlite_master rows=${names.length} names=${names.join(',')}`, true) + } else { + this.writeLog(`[diag:open] contact sqlite_master failed: ${contactProbe.error || 'unknown'}`, true) + } + + const contactCount = await this.execQuery('contact', null, 'SELECT COUNT(1) AS cnt FROM contact') + if (contactCount.success && Array.isArray(contactCount.rows) && contactCount.rows.length > 0) { + this.writeLog(`[diag:open] contact count=${String((contactCount.rows[0] as any)?.cnt ?? '')}`, true) + } else { + this.writeLog(`[diag:open] contact count failed: ${contactCount.error || 'unknown'}`, true) + } + } catch (e) { + this.writeLog(`[diag:open] post-open diagnostics exception: ${String(e)}`, true) + } } /** @@ -376,6 +473,51 @@ export class WcdbCore { return null } + private isRealDbFileName(name: string): boolean { + const lower = String(name || '').toLowerCase() + if (!lower.endsWith('.db')) return false + if (lower.endsWith('.db-shm')) return false + if (lower.endsWith('.db-wal')) return false + if (lower.endsWith('.db-journal')) return false + return true + } + + private resolveContactDbPath(): string | null { + const dbStorage = this.currentDbStoragePath || this.resolveDbStoragePath(this.currentPath || '', this.currentWxid || '') + if (!dbStorage) return null + const contactDir = join(dbStorage, 'Contact') + if (!existsSync(contactDir)) return null + + const preferred = [ + join(contactDir, 'contact.db'), + join(contactDir, 'Contact.db') + ] + for (const p of preferred) { + if (existsSync(p)) return p + } + + try { + const entries = readdirSync(contactDir) + const cands = entries + .filter((name) => this.isRealDbFileName(name)) + .map((name) => join(contactDir, name)) + if (cands.length > 0) return cands[0] + } catch { } + return null + } + + private pickFirstStringField(row: Record, candidates: string[]): string { + for (const key of candidates) { + const v = row[key] + if (typeof v === 'string' && v.trim()) return v + if (v !== null && v !== undefined) { + const s = String(v).trim() + if (s) return s + } + } + return '' + } + /** * 初始化 WCDB */ @@ -385,31 +527,49 @@ export class WcdbCore { try { this.koffi = require('koffi') const dllPath = this.getDllPath() + this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true) if (!existsSync(dllPath)) { console.error('WCDB DLL 不存在:', dllPath) + this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true) return false } const dllDir = dirname(dllPath) - const wcdbCorePath = join(dllDir, 'WCDB.dll') - if (existsSync(wcdbCorePath)) { - try { - this.koffi.load(wcdbCorePath) - this.writeLog('预加载 WCDB.dll 成功') - } catch (e) { - console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e) - this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`) + const isMac = process.platform === 'darwin' + + // 预加载依赖库 + if (isMac) { + const wcdbCorePath = join(dllDir, 'libWCDB.dylib') + if (existsSync(wcdbCorePath)) { + try { + this.koffi.load(wcdbCorePath) + this.writeLog('预加载 libWCDB.dylib 成功') + } catch (e) { + console.warn('预加载 libWCDB.dylib 失败(可能不是致命的):', e) + this.writeLog(`预加载 libWCDB.dylib 失败: ${String(e)}`) + } } - } - const sdl2Path = join(dllDir, 'SDL2.dll') - if (existsSync(sdl2Path)) { - try { - this.koffi.load(sdl2Path) - this.writeLog('预加载 SDL2.dll 成功') - } catch (e) { - console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e) - this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`) + } else { + const wcdbCorePath = join(dllDir, 'WCDB.dll') + if (existsSync(wcdbCorePath)) { + try { + this.koffi.load(wcdbCorePath) + this.writeLog('预加载 WCDB.dll 成功') + } catch (e) { + console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e) + this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`) + } + } + const sdl2Path = join(dllDir, 'SDL2.dll') + if (existsSync(sdl2Path)) { + try { + this.koffi.load(sdl2Path) + this.writeLog('预加载 SDL2.dll 成功') + } catch (e) { + console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e) + this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`) + } } } @@ -982,7 +1142,7 @@ export class WcdbCore { } const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) - this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) + this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true) if (!dbStoragePath || !existsSync(dbStoragePath)) { console.error('数据库目录不存在:', dbPath) @@ -991,7 +1151,7 @@ export class WcdbCore { } const sessionDbPath = this.findSessionDb(dbStoragePath) - this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`) + this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`, true) if (!sessionDbPath) { console.error('未找到 session.db 文件') this.writeLog('open failed: session.db not found') @@ -1017,6 +1177,7 @@ export class WcdbCore { this.currentPath = dbPath this.currentKey = hexKey this.currentWxid = wxid + this.currentDbStoragePath = dbStoragePath this.initialized = true if (this.wcdbSetMyWxid && wxid) { try { @@ -1028,7 +1189,9 @@ export class WcdbCore { if (this.isLogEnabled()) { this.startLogPolling() } - this.writeLog(`open ok handle=${handle}`) + this.writeLog(`open ok handle=${handle}`, true) + await this.dumpDbStatus('open') + await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid) return true } catch (e) { console.error('打开数据库异常:', e) @@ -1053,6 +1216,7 @@ export class WcdbCore { this.currentPath = null this.currentKey = null this.currentWxid = null + this.currentDbStoragePath = null this.initialized = false this.stopLogPolling() } @@ -1208,6 +1372,31 @@ export class WcdbCore { } if (usernames.length === 0) return { success: true, map: {} } try { + if (process.platform === 'darwin') { + const uniq = Array.from(new Set(usernames.map((x) => String(x || '').trim()).filter(Boolean))) + if (uniq.length === 0) return { success: true, map: {} } + const inList = uniq.map((u) => `'${u.replace(/'/g, "''")}'`).join(',') + const sql = `SELECT * FROM contact WHERE username IN (${inList})` + const q = await this.execQuery('contact', null, sql) + if (!q.success) return { success: false, error: q.error || '获取昵称失败' } + const map: Record = {} + for (const row of (q.rows || []) as Array>) { + const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) + if (!username) continue + const display = this.pickFirstStringField(row, [ + 'remark', 'Remark', + 'nick_name', 'nickName', 'nickname', 'NickName', + 'alias', 'Alias' + ]) || username + map[username] = display + } + // 保证每个请求用户名至少有回退值 + for (const u of uniq) { + if (!map[u]) map[u] = u + } + return { success: true, map } + } + // 让出控制权,避免阻塞事件循环 await new Promise(resolve => setImmediate(resolve)) @@ -1256,6 +1445,34 @@ export class WcdbCore { return { success: true, map: resultMap } } + if (process.platform === 'darwin') { + const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',') + const sql = `SELECT * FROM contact WHERE username IN (${inList})` + const q = await this.execQuery('contact', null, sql) + if (!q.success) { + if (Object.keys(resultMap).length > 0) { + return { success: true, map: resultMap, error: q.error || '获取头像失败' } + } + return { success: false, error: q.error || '获取头像失败' } + } + + for (const row of (q.rows || []) as Array>) { + const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) + if (!username) continue + const url = this.pickFirstStringField(row, [ + 'big_head_img_url', 'bigHeadImgUrl', 'bigHeadUrl', 'big_head_url', + 'small_head_img_url', 'smallHeadImgUrl', 'smallHeadUrl', 'small_head_url', + 'head_img_url', 'headImgUrl', + 'avatar_url', 'avatarUrl' + ]) + if (url) { + resultMap[username] = url + this.avatarUrlCache.set(username, { url, updatedAt: now }) + } + } + return { success: true, map: resultMap } + } + // 让出控制权,避免阻塞事件循环 await new Promise(resolve => setImmediate(resolve)) @@ -1464,10 +1681,42 @@ export class WcdbCore { return { success: false, error: 'WCDB 未连接' } } try { + if (process.platform === 'darwin') { + const safe = String(username || '').replace(/'/g, "''") + const sql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1` + const q = await this.execQuery('contact', null, sql) + if (!q.success) { + return { success: false, error: q.error || '获取联系人失败' } + } + const row = Array.isArray(q.rows) && q.rows.length > 0 ? q.rows[0] : null + if (!row) { + return { success: false, error: `联系人不存在: ${username}` } + } + return { success: true, contact: row } + } + const outPtr = [null as any] const result = this.wcdbGetContact(this.handle, username, outPtr) if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取联系人失败: ${result}` } + this.writeLog(`[diag:getContact] primary api failed username=${username} code=${result} outPtr=${outPtr[0] ? 'set' : 'null'}`, true) + await this.dumpDbStatus('getContact-primary-fail') + await this.printLogs(true) + + // Fallback: 直接查询 contact 表,便于区分是接口失败还是 contact 库本身不可读。 + const safe = String(username || '').replace(/'/g, "''") + const fallbackSql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1` + const fallback = await this.execQuery('contact', null, fallbackSql) + if (fallback.success) { + const row = Array.isArray(fallback.rows) ? fallback.rows[0] : null + if (row) { + this.writeLog(`[diag:getContact] fallback sql hit username=${username}`, true) + return { success: true, contact: row } + } + this.writeLog(`[diag:getContact] fallback sql no row username=${username}`, true) + return { success: false, error: `联系人不存在: ${username}` } + } + this.writeLog(`[diag:getContact] fallback sql failed username=${username} err=${fallback.error || 'unknown'}`, true) + return { success: false, error: `获取联系人失败: ${result}; fallback=${fallback.error || 'unknown'}` } } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析联系人失败' } @@ -1804,16 +2053,43 @@ export class WcdbCore { console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)') } + const normalizedKind = String(kind || '').toLowerCase() + const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) + let effectivePath = path || '' + if (normalizedKind === 'contact' && !effectivePath) { + const resolvedContactDb = this.resolveContactDbPath() + if (resolvedContactDb) { + effectivePath = resolvedContactDb + this.writeLog(`[diag:execQuery] contact path override -> ${effectivePath}`, true) + } else { + this.writeLog('[diag:execQuery] contact path override miss: Contact/contact.db not found', true) + } + } + const outPtr = [null as any] - const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr) + const result = this.wcdbExecQuery(this.handle, kind, effectivePath, sql, outPtr) if (result !== 0 || !outPtr[0]) { + if (isContactQuery) { + this.writeLog(`[diag:execQuery] contact query failed code=${result} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) + await this.dumpDbStatus('execQuery-contact-fail') + await this.printLogs(true) + } return { success: false, error: `执行查询失败: ${result}` } } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析查询结果失败' } const rows = JSON.parse(jsonStr) + if (isContactQuery) { + const count = Array.isArray(rows) ? rows.length : -1 + this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) + } return { success: true, rows } } catch (e) { + const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) + if (isContactQuery) { + this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true) + await this.dumpDbStatus('execQuery-contact-exception') + } return { success: false, error: String(e) } } } diff --git a/package.json b/package.json index 7e19d6f..1666f1e 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,17 @@ "directories": { "output": "release" }, + "mac": { + "target": [ + "dmg", + "zip" + ], + "category": "public.app-category.utilities", + "hardenedRuntime": false, + "gatekeeperAssess": false, + "entitlements": "electron/entitlements.mac.plist", + "entitlementsInherit": "electron/entitlements.mac.plist" + }, "win": { "target": [ "nsis" diff --git a/resources/libwx_key.dylib b/resources/libwx_key.dylib new file mode 100755 index 0000000..10773c0 Binary files /dev/null and b/resources/libwx_key.dylib differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index 4e2b144..3834eab 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 4d86715..4208707 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/wx_key.dll b/resources/wx_key.dll index 5edf298..9ab5f7b 100644 Binary files a/resources/wx_key.dll and b/resources/wx_key.dll differ diff --git a/resources/xkey_helper b/resources/xkey_helper new file mode 100755 index 0000000..6ffda57 Binary files /dev/null and b/resources/xkey_helper differ