From bcf8f331b2365fab9f66a4d0dfa68f14e882683b Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Mon, 30 Mar 2026 02:59:27 +0800 Subject: [PATCH] feat: add macOS key and decrypt integration --- MACOS_PORT_GUIDE.md | 312 +++++ electron/main.ts | 114 +- electron/preload.ts | 1 + electron/services/dataManagementService.ts | 23 +- electron/services/dbPathService.ts | 214 +++- electron/services/decryptService.ts | 10 +- electron/services/imageDecryptService.ts | 21 +- electron/services/nativeDecryptService.ts | 49 +- electron/services/platformService.ts | 75 ++ electron/services/videoService.ts | 17 +- electron/services/wcdbService.ts | 392 +++--- electron/services/windowsHelloService.ts | 10 + electron/services/wxKeyServiceMac.ts | 1123 +++++++++++++++++ electron/workers/decryptWorker.js | 12 +- package.json | 7 + resources/macos/entitlements.mac.plist | 14 + resources/macos/image_scan_entitlements.plist | 10 + src/pages/SettingsPage.tsx | 164 ++- src/pages/WelcomePage.tsx | 215 ++-- src/stores/authStore.ts | 17 + src/types/electron.d.ts | 1 + 21 files changed, 2309 insertions(+), 492 deletions(-) create mode 100644 MACOS_PORT_GUIDE.md create mode 100644 electron/services/platformService.ts create mode 100644 electron/services/wxKeyServiceMac.ts create mode 100644 resources/macos/entitlements.mac.plist create mode 100644 resources/macos/image_scan_entitlements.plist diff --git a/MACOS_PORT_GUIDE.md b/MACOS_PORT_GUIDE.md new file mode 100644 index 0000000..5267237 --- /dev/null +++ b/MACOS_PORT_GUIDE.md @@ -0,0 +1,312 @@ +# macOS 密钥支持移植指南 + +## 1. 目标与归属 + +本仓库的 macOS 微信密钥与解密支持,正式归属在 `native-dlls/` 与 `electron/services/`。 + +- `native-dlls/` 是正式原生实现入口。 +- `resources/macos/` 是正式运行时产物目录。 +- `WxKey-CC/` 只保留为上游镜像参考,不作为运行时依赖目录。 +- `WeFlow/` 只用于对照 TS 流程和资源布局,不直接参与当前项目发布。 + +当前已接入的 mac 产物命名如下: + +- `resources/macos/libwx_key.dylib` +- `resources/macos/xkey_helper` +- `resources/macos/image_scan_helper` +- `resources/macos/libwcdb_api.dylib` +- `resources/macos/libwcdb_decrypt.dylib` + +## 2. 关键事实 + +### 2.1 DbKey 获取不是直接靠 `GetDbKey()` + +`WxKey-CC/platform/macos/exports.cpp` 中的 `GetDbKey()` 只负责: + +- 定位微信主进程 +- 附加进程 +- 扫描出目标断点地址 + +它返回的不是最终 64 位 DbKey,而是目标地址或错误字符串。 + +真正的 DbKey 由 `helper_main.cpp` 对应的 helper 进程通过断点捕获得到。当前项目运行时走的是: + +1. Electron 主进程调用 `wxKeyServiceMac.autoGetDbKey()` +2. 检查 SIP +3. 用 AppleScript `with administrator privileges` 拉起 `xkey_helper` +4. `xkey_helper ` 附加微信并等待数据库访问 +5. helper 从 stdout 返回 JSON,主进程解析出最终 64 位 DbKey + +### 2.2 helper 协议 + +`xkey_helper` 的真实调用方式是: + +```bash +xkey_helper [timeout_ms] +``` + +stdout 返回 JSON: + +```json +{"success":true,"key":"64_hex_key"} +``` + +失败时 stdout 仍返回 JSON,但错误内容来自 helper 内部 `ERROR:*` 结果映射。 + +### 2.3 SIP 前置条件 + +本期接受以下现实限制,并且 UI 与文档都必须明确写出: + +- DbKey 抓取要求关闭 SIP +- 图片密钥的内存扫描兜底要求关闭 SIP +- `kvcomm + wxid` 验真路径优先,不要求先做内存扫描 + +检查方式: + +```bash +csrutil status +``` + +如果输出包含 `enabled`,当前项目会在 mac 上直接拒绝自动抓取 DbKey。 + +## 3. 当前仓库实现落点 + +### 3.1 Electron 主进程 + +- `electron/main.ts` + - `wxkey:*` IPC 已做平台分支 + - `imageKey:getImageKeys` 已做平台分支 + - `dbpath:getBestCachePath` 已做平台分支 + - 新增 `app:getPlatformInfo` + +### 3.2 Electron 服务层 + +- `electron/services/wxKeyServiceMac.ts` + - DbKey helper 提权流程 + - SIP 检查 + - mac 微信进程识别 + - kvcomm 优先的图片密钥推导 + - `image_scan_helper` / Mach API 内存扫描兜底 +- `electron/services/dbPathService.ts` + - 支持旧版 `xwechat_files` + - 支持 4.0.5+ 新路径 `~/Library/Application Support/com.tencent.xinWeChat/` +- `electron/services/wcdbService.ts` + - mac 加载 `resources/macos/libwcdb_api.dylib` +- `electron/services/nativeDecryptService.ts` + - mac 加载 `resources/macos/libwcdb_decrypt.dylib` + +### 3.3 前端与引导页 + +- `src/pages/WelcomePage.tsx` +- `src/pages/SettingsPage.tsx` + +这两处已经需要按平台隐藏或替换: + +- `Weixin.exe` +- Windows Hook 提示 +- 盘符缓存目录提示 +- Windows Hello 入口 + +## 4. 原生构建入口 + +正式构建入口是: + +```bash +native-dlls/build-macos.sh +``` + +该脚本只负责构建 native 产物,不负责 Electron 整包构建。 + +脚本做的事情: + +1. 使用 `native-dlls/sqlcipher_src` 构建 SQLCipher +2. 配置 `native-dlls/CMakeLists.txt` +3. 构建 `wx_key`、`wcdb_api`、`wcdb_decrypt` +4. 将产物输出到 `resources/macos/` +5. 为 helper 补 `chmod +x` +6. 在本机存在 `codesign` 时做 ad-hoc 签名 + +## 5. CMake 真实目标 + +当前 `native-dlls/` 下的 mac 目标如下: + +- `wx_key` + - 输出 `libwx_key.dylib` + - 源码来自 `WxKey-CC/platform/common + platform/macos` +- `xkey_helper` + - 输出 `resources/macos/xkey_helper` + - 源码来自 `WxKey-CC/platform/macos/helper_main.cpp` +- `image_scan_helper` + - 输出 `resources/macos/image_scan_helper` + - 是当前项目自己的轻量包装器 +- `wcdb_api` + - 输出 `libwcdb_api.dylib` + - mac 侧链接仓库内构建出的 SQLCipher +- `wcdb_decrypt` + - 输出 `libwcdb_decrypt.dylib` + - mac 侧使用 CommonCrypto + +不是文档里旧写法的这些目标: + +- 不是 `libwx_key` target +- 不是 `xkey_helper_macos` 运行时名称 +- 不是直接从 `WxKey-CC/` 目录拿产物进包 + +## 6. 资源目录 + +运行时统一读取: + +```text +resources/macos/ + libwx_key.dylib + xkey_helper + image_scan_helper + libwcdb_api.dylib + libwcdb_decrypt.dylib + entitlements.mac.plist + image_scan_entitlements.plist +``` + +Electron 打包时通过 `package.json -> build.extraResources` 带入整个 `resources/`,不需要单独再为 `WxKey-CC/` 配额外资源。 + +## 7. 数据路径识别 + +当前项目在 mac 上要识别两类微信数据目录: + +### 7.1 旧路径 + +```text +~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files +~/Documents/xwechat_files +``` + +### 7.2 新路径 + +```text +~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/ +``` + +对外给前端的仍然是“数据库根目录”概念,但后端会继续向下解析: + +- 账号目录 +- `db_storage` +- `session.db` + +## 8. 图片密钥策略 + +当前项目在 mac 上使用的顺序是: + +1. `kvcomm` 码收集 +2. 基于 `wxid` 的候选验真 +3. 如果失败,再调用 `image_scan_helper` 或 Mach API 做内存扫描 + +对外保持原有返回结构: + +```ts +{ success, xorKey?, aesKey?, error? } +``` + +其中: + +- `xorKey` 仍然给当前项目直接消费 +- `aesKey` 仍然是 16 位字符串 + +## 9. WCDB 与解密 + +### 9.1 `wcdb_api` + +当前做法是跨平台 C ABI,不再依赖 Windows 风格导出写法。 + +- Windows 仍可保留现有 `WCDB.dll` 资源装载 +- mac 侧改为链接 SQLCipher +- 统一导出: + - `wcdb_init` + - `wcdb_open_account` + - `wcdb_close_account` + - `wcdb_exec_query` + - `wcdb_get_sns_timeline` + - `wcdb_test_connection` + +### 9.2 `wcdb_decrypt` + +- Windows 保留现有 CNG 版本 +- mac 新增 CommonCrypto 版本 +- 导出函数名不变: + - `Wcdb_DecryptDatabase` + - `Wcdb_DecryptDatabaseWithProgress` + - `Wcdb_ValidateKey` + - `Wcdb_IsDecrypted` + - `Wcdb_GetLastErrorMsg` + +## 10. 打包配置 + +`package.json` 已预留 mac 打包配置: + +- `build.mac.hardenedRuntime` +- `build.mac.entitlements` +- `build.mac.entitlementsInherit` + +注意: + +- 这只是为后续打包接入准备 +- 当前计划不包含执行 Electron 整包构建 + +## 11. 推荐开发流程 + +### 11.1 只开发 native + +```bash +cd native-dlls +chmod +x build-macos.sh +./build-macos.sh +``` + +### 11.2 只验证产物 + +验证输出文件是否齐全: + +```bash +ls -la resources/macos +``` + +检查 helper 是否可执行: + +```bash +file resources/macos/xkey_helper +file resources/macos/image_scan_helper +``` + +检查 dylib 符号: + +```bash +nm -gU resources/macos/libwx_key.dylib | grep GetDbKey +nm -gU resources/macos/libwcdb_api.dylib | grep wcdb_open_account +nm -gU resources/macos/libwcdb_decrypt.dylib | grep Wcdb_DecryptDatabase +``` + +### 11.3 只验证 helper 协议 + +```bash +resources/macos/xkey_helper 180000 +``` + +预期 stdout 是 JSON,而不是裸字符串。 + +## 12. 本期边界 + +本期明确不做: + +- Touch ID / Keychain 应用锁 +- 在 mac 上伪装 Windows Hello +- 直接把 `WxKey-CC/` 当运行时资源目录 +- 自动执行 Electron 整包构建 + +## 13. 后续同步规则 + +后续如果 `WxKey-CC` 上游更新: + +1. 先对比 `WxKey-CC/platform/common` 与 `platform/macos` +2. 再手工回灌到 `native-dlls/` 的构建入口和项目包装层 +3. 不直接改当前项目去依赖 `WxKey-CC/` 目录运行 + +这份文档以当前仓库事实为准。如果实现与文档再次偏离,优先修正文档和 `native-dlls/` 构建入口,不要继续在 Electron 层堆临时兼容逻辑。 diff --git a/electron/main.ts b/electron/main.ts index e38a41e..b16d3b0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -7,6 +7,7 @@ import { DatabaseService } from './services/database' import { wechatDecryptService } from './services/decryptService' import { ConfigService } from './services/config' import { wxKeyService } from './services/wxKeyService' +import { wxKeyServiceMac } from './services/wxKeyServiceMac' import { dbPathService } from './services/dbPathService' import { wcdbService } from './services/wcdbService' import { dataManagementService } from './services/dataManagementService' @@ -26,6 +27,7 @@ import { voiceTranscribeServiceWhisper } from './services/voiceTranscribeService import { windowsHelloService, WindowsHelloResult } from './services/windowsHelloService' import { shortcutService } from './services/shortcutService' import { httpApiService } from './services/httpApiService' +import { getBestCachePath, getRuntimePlatformInfo } from './services/platformService' // 扩展 app 对象类型,添加 isQuitting 标志 declare module 'electron' { @@ -1285,6 +1287,10 @@ function registerIpcHandlers() { return app.getVersion() }) + ipcMain.handle('app:getPlatformInfo', async () => { + return getRuntimePlatformInfo() + }) + ipcMain.handle('app:checkForUpdates', async () => { try { const result = await autoUpdater.checkForUpdates() @@ -1511,27 +1517,62 @@ function registerIpcHandlers() { // 密钥获取相关 ipcMain.handle('wxkey:isWeChatRunning', async () => { + if (process.platform === 'darwin') { + return wxKeyServiceMac.isWeChatRunning() + } return wxKeyService.isWeChatRunning() }) ipcMain.handle('wxkey:getWeChatPid', async () => { + if (process.platform === 'darwin') { + return wxKeyServiceMac.getWeChatPid() + } return wxKeyService.getWeChatPid() }) ipcMain.handle('wxkey:killWeChat', async () => { + if (process.platform === 'darwin') { + return wxKeyServiceMac.killWeChat() + } return wxKeyService.killWeChat() }) - ipcMain.handle('wxkey:launchWeChat', async () => { - return wxKeyService.launchWeChat() + ipcMain.handle('wxkey:launchWeChat', async (_, customWechatPath?: string) => { + if (process.platform === 'darwin') { + return wxKeyServiceMac.launchWeChat(customWechatPath) + } + return wxKeyService.launchWeChat(customWechatPath) }) ipcMain.handle('wxkey:waitForWindow', async (_, maxWaitSeconds?: number) => { + if (process.platform === 'darwin') { + return wxKeyServiceMac.waitForWeChatWindow(maxWaitSeconds) + } return wxKeyService.waitForWeChatWindow(maxWaitSeconds) }) ipcMain.handle('wxkey:startGetKey', async (event, customWechatPath?: string) => { logService?.info('WxKey', '开始获取微信密钥', { customWechatPath }) + if (process.platform === 'darwin') { + try { + 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 { + logService?.warn('WxKey', 'macOS 数据库密钥获取失败', { error: result.error }) + } + + return result + } catch (e) { + wxKeyServiceMac.dispose() + logService?.error('WxKey', 'macOS 获取密钥异常', { error: String(e) }) + return { success: false, error: String(e) } + } + } + try { // 初始化 DLL const initSuccess = await wxKeyService.initialize() @@ -1624,11 +1665,18 @@ function registerIpcHandlers() { }) ipcMain.handle('wxkey:cancel', async () => { + if (process.platform === 'darwin') { + wxKeyServiceMac.dispose() + return true + } wxKeyService.dispose() return true }) ipcMain.handle('wxkey:detectCurrentAccount', async (_, dbPath?: string, maxTimeDiffMinutes?: number) => { + if (process.platform === 'darwin') { + return wxKeyServiceMac.detectCurrentAccount(dbPath, maxTimeDiffMinutes) + } return wxKeyService.detectCurrentAccount(dbPath, maxTimeDiffMinutes) }) @@ -1647,26 +1695,9 @@ function registerIpcHandlers() { // 获取最佳缓存目录 ipcMain.handle('dbpath:getBestCachePath', async () => { - const { existsSync } = require('fs') - const { join } = require('path') - - // 按优先级检查磁盘:D、E、F、C - const drives = ['D', 'E', 'F', 'C'] - - for (const drive of drives) { - const drivePath = `${drive}:\\` - if (existsSync(drivePath)) { - const cachePath = join(drivePath, 'CipherTalkDB') - logService?.info('CachePath', `找到可用磁盘: ${drive}`, { cachePath }) - return { success: true, path: cachePath, drive } - } - } - - // 如果都没有,返回用户目录下的默认路径 - const { app } = require('electron') - const defaultPath = join(app.getPath('userData'), 'cache') - logService?.warn('CachePath', '未找到常规磁盘,使用默认路径', { defaultPath }) - return { success: true, path: defaultPath, drive: 'default' } + const result = getBestCachePath() + logService?.info('CachePath', '返回平台默认缓存目录', result) + return result }) // WCDB 数据库相关 @@ -1917,6 +1948,45 @@ function registerIpcHandlers() { // 图片密钥获取(通过 DLL 从缓存目录获取 code,用前端 wxid 计算密钥) ipcMain.handle('imageKey:getImageKeys', async (event, userDir: string) => { logService?.info('ImageKey', '开始获取图片密钥(DLL 本地扫描模式)', { userDir }) + if (process.platform === 'darwin') { + try { + const kvcommResult = await wxKeyServiceMac.autoGetImageKey( + userDir, + (message) => event.sender.send('imageKey:progress', message) + ) + + if (kvcommResult.success) { + logService?.info('ImageKey', 'macOS kvcomm 图片密钥获取成功', { + xorKey: kvcommResult.xorKey, + aesKey: kvcommResult.aesKey + }) + return kvcommResult + } + + logService?.warn('ImageKey', 'macOS kvcomm 方案失败,切换内存扫描', { error: kvcommResult.error }) + event.sender.send('imageKey:progress', 'kvcomm 方案失败,正在尝试内存扫描...') + + const scanResult = await wxKeyServiceMac.autoGetImageKeyByMemoryScan( + userDir, + (message) => event.sender.send('imageKey:progress', message) + ) + + if (scanResult.success) { + logService?.info('ImageKey', 'macOS 内存扫描图片密钥获取成功', { + xorKey: scanResult.xorKey, + aesKey: scanResult.aesKey + }) + } else { + logService?.error('ImageKey', 'macOS 图片密钥获取失败', { error: scanResult.error }) + } + + return scanResult + } catch (e) { + logService?.error('ImageKey', 'macOS 图片密钥获取异常', { error: String(e) }) + return { success: false, error: String(e) } + } + } + try { // ========== 方案一:DLL 本地扫描(优先) ========== const dllResult = await (async () => { diff --git a/electron/preload.ts b/electron/preload.ts index d8ca35a..a7a46c5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -47,6 +47,7 @@ contextBridge.exposeInMainWorld('electronAPI', { app: { getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'), getVersion: () => ipcRenderer.invoke('app:getVersion'), + getPlatformInfo: () => ipcRenderer.invoke('app:getPlatformInfo'), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), getStartupDbConnected: () => ipcRenderer.invoke('app:getStartupDbConnected'), diff --git a/electron/services/dataManagementService.ts b/electron/services/dataManagementService.ts index 27d37c9..2e91383 100644 --- a/electron/services/dataManagementService.ts +++ b/electron/services/dataManagementService.ts @@ -6,6 +6,7 @@ import { wechatDecryptService } from './decryptService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' import { snsService } from './snsService' +import { getDefaultCachePath as getPlatformDefaultCachePath } from './platformService' // 文件系统监听器类型 type FileWatcher = fs.FSWatcher | null @@ -738,27 +739,7 @@ class DataManagementService { * - 如果安装在其他盘:使用软件安装目录 */ getDefaultCachePath(): string { - // 开发环境使用文档目录 - if (process.env.VITE_DEV_SERVER_URL) { - const documentsPath = app.getPath('documents') - return path.join(documentsPath, 'CipherTalkData') - } - - // 生产环境 - const exePath = app.getPath('exe') - const installDir = path.dirname(exePath) - - // 检查是否安装在 C 盘(Windows) - const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\') - - if (isOnCDrive) { - // C 盘可能有写入权限问题,使用文档目录 - const documentsPath = app.getPath('documents') - return path.join(documentsPath, 'CipherTalkData') - } - - // 其他盘使用软件安装目录 - return path.join(installDir, 'CipherTalkData') + return getPlatformDefaultCachePath() } /** diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index 7939203..e090985 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -1,33 +1,19 @@ -import { join } from 'path' +import { basename, join } from 'path' import { existsSync, readdirSync, statSync } from 'fs' import { homedir } from 'os' export class DbPathService { - /** - * 自动检测微信数据库根目录 - */ async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> { try { - const possiblePaths: string[] = [] - const home = homedir() + for (const candidate of this.getPossibleRoots()) { + if (!existsSync(candidate)) continue - // 微信4.x 数据目录 - possiblePaths.push(join(home, 'Documents', 'xwechat_files')) - // 旧版微信数据目录 - possiblePaths.push(join(home, 'Documents', 'WeChat Files')) + if (this.isAccountDir(candidate)) { + return { success: true, path: candidate } + } - for (const path of possiblePaths) { - if (existsSync(path)) { - const rootName = path.split(/[/\\]/).pop()?.toLowerCase() - if (rootName !== 'xwechat_files' && rootName !== 'wechat files') { - continue - } - - // 检查是否有有效的账号目录 - const accounts = this.findAccountDirs(path) - if (accounts.length > 0) { - return { success: true, path } - } + if (this.findAccountDirs(candidate).length > 0) { + return { success: true, path: candidate } } } @@ -37,56 +23,158 @@ export class DbPathService { } } - /** - * 查找账号目录(包含 db_storage 的目录) - */ - findAccountDirs(rootPath: string): string[] { - const accounts: string[] = [] - - try { - const entries = readdirSync(rootPath) - - for (const entry of entries) { - const entryPath = join(rootPath, entry) - const stat = statSync(entryPath) - - if (stat.isDirectory()) { - // 检查是否有 db_storage 子目录 - const dbStoragePath = join(entryPath, 'db_storage') - if (existsSync(dbStoragePath)) { - accounts.push(entry) - } - } - } - } catch {} - - return accounts - } - - /** - * 扫描 wxid 列表 - * 微信账号目录格式多样: - * - wxid_xxxxx(传统格式) - * - 纯数字(QQ号绑定) - * - 自定义微信号格式(如 chenggongyouyue003_03d9) - */ scanWxids(rootPath: string): string[] { try { - // 直接返回所有包含 db_storage 的账号目录 - // 不再限制 wxid 格式,因为微信账号目录名称格式多样 + if (this.isAccountDir(rootPath)) { + return [basename(rootPath)] + } return this.findAccountDirs(rootPath) - } catch {} - - return [] + } catch { + return [] + } } - /** - * 获取默认数据库路径 - */ getDefaultPath(): string { const home = homedir() + if (process.platform === 'darwin') { + const appSupportBase = join( + home, + 'Library', + 'Containers', + 'com.tencent.xinWeChat', + 'Data', + 'Library', + 'Application Support', + 'com.tencent.xinWeChat' + ) + + for (const entry of this.safeReadDir(appSupportBase)) { + if (this.isMacVersionDir(entry)) { + return join(appSupportBase, entry) + } + } + + return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files') + } + return join(home, 'Documents', 'xwechat_files') } + + private getPossibleRoots(): string[] { + const home = homedir() + const possiblePaths: string[] = [] + + if (process.platform === 'darwin') { + const appSupportBase = join( + home, + 'Library', + 'Containers', + 'com.tencent.xinWeChat', + 'Data', + 'Library', + 'Application Support', + 'com.tencent.xinWeChat' + ) + + for (const entry of this.safeReadDir(appSupportBase)) { + if (this.isMacVersionDir(entry)) { + possiblePaths.push(join(appSupportBase, entry)) + } + } + + possiblePaths.push( + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'), + join(home, 'Documents', 'xwechat_files'), + join(home, 'Documents', 'WeChat Files') + ) + return possiblePaths + } + + return [ + join(home, 'Documents', 'xwechat_files'), + join(home, 'Documents', 'WeChat Files') + ] + } + + private findAccountDirs(rootPath: string): string[] { + const accounts: string[] = [] + + try { + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + if (!this.isPotentialAccountName(entry.name)) continue + + const entryPath = join(rootPath, entry.name) + if (this.isAccountDir(entryPath)) { + accounts.push(entry.name) + } + } + } catch { + // ignore + } + + return accounts.sort((a, b) => { + const aTime = this.getAccountModifiedTime(join(rootPath, a)) + const bTime = this.getAccountModifiedTime(join(rootPath, b)) + if (bTime !== aTime) return bTime - aTime + return a.localeCompare(b) + }) + } + + private isAccountDir(entryPath: string): boolean { + return ( + existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) || + existsSync(join(entryPath, 'msg', 'attach')) + ) + } + + private isPotentialAccountName(name: string): boolean { + const lower = name.toLowerCase() + return !( + lower.startsWith('all') || + lower.startsWith('applet') || + lower.startsWith('backup') || + lower.startsWith('wmpf') || + lower.startsWith('app_data') + ) + } + + private isMacVersionDir(name: string): boolean { + return /^\d+\.\d+b\d+\.\d+/.test(name) || /^\d+\.\d+\.\d+/.test(name) + } + + private getAccountModifiedTime(entryPath: string): number { + try { + const accountStat = statSync(entryPath) + let latest = accountStat.mtimeMs + + for (const candidate of [ + join(entryPath, 'db_storage'), + join(entryPath, 'FileStorage', 'Image'), + join(entryPath, 'FileStorage', 'Image2'), + join(entryPath, 'msg', 'attach') + ]) { + if (existsSync(candidate)) { + latest = Math.max(latest, statSync(candidate).mtimeMs) + } + } + + return latest + } catch { + return 0 + } + } + + private safeReadDir(dirPath: string): string[] { + try { + if (!existsSync(dirPath)) return [] + return readdirSync(dirPath) + } catch { + return [] + } + } } export const dbPathService = new DbPathService() diff --git a/electron/services/decryptService.ts b/electron/services/decryptService.ts index 5b81ba9..96543ba 100644 --- a/electron/services/decryptService.ts +++ b/electron/services/decryptService.ts @@ -1,8 +1,8 @@ import { nativeDecryptService } from './nativeDecryptService' /** - * 微信数据库解密服务 (Windows v4) - * 纯原生 DLL 实现封装 + * 微信数据库解密服务 + * 纯原生库实现封装 */ export class WeChatDecryptService { @@ -16,7 +16,7 @@ export class WeChatDecryptService { /** * 解密数据库 - * 使用原生 DLL 解密(高性能、异步不卡顿) + * 使用原生库解密(高性能、异步不卡顿) */ async decryptDatabase( inputPath: string, @@ -27,13 +27,13 @@ export class WeChatDecryptService { // 检查服务是否可用 if (!nativeDecryptService.isAvailable()) { - return { success: false, error: '原生解密服务不可用:DLL 加载失败或 Worker 未启动' } + return { success: false, error: '原生解密服务不可用:原生库加载失败或 Worker 未启动' } } try { // console.log(`[Decrypt] 开始解密: ${inputPath} -> ${outputPath}`) // 减少日志 - // 使用异步 DLL 解密 + // 使用异步原生库解密 const result = await nativeDecryptService.decryptDatabaseAsync(inputPath, outputPath, hexKey, onProgress) if (result.success) { diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index b1f8864..403a4e0 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -9,6 +9,7 @@ import { Worker } from 'worker_threads' import { execFile } from 'child_process' import { promisify } from 'util' import { ConfigService } from './config' +import { getDefaultCachePath as getPlatformDefaultCachePath } from './platformService' const execFileAsync = promisify(execFile) @@ -1504,25 +1505,7 @@ export class ImageDecryptService { * 获取默认缓存路径(与 dataManagementService 保持一致) */ private getDefaultCachePath(): string { - // 开发环境使用文档目录 - if (process.env.VITE_DEV_SERVER_URL) { - const documentsPath = app.getPath('documents') - return join(documentsPath, 'CipherTalkData') - } - - // 生产环境 - const exePath = app.getPath('exe') - const installDir = require('path').dirname(exePath) - - // 检查是否安装在 C 盘 - const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\\\') - - if (isOnCDrive) { - const documentsPath = app.getPath('documents') - return join(documentsPath, 'CipherTalkData') - } - - return join(installDir, 'CipherTalkData') + return getPlatformDefaultCachePath() } private getCacheRoot(): string { diff --git a/electron/services/nativeDecryptService.ts b/electron/services/nativeDecryptService.ts index 3237527..39921c9 100644 --- a/electron/services/nativeDecryptService.ts +++ b/electron/services/nativeDecryptService.ts @@ -23,7 +23,7 @@ interface DecryptTask { */ export class NativeDecryptService { private worker: Worker | null = null - private dllPath: string | null = null + private nativeLibPath: string | null = null private initialized: boolean = false private initError: string | null = null private tasks: Map = new Map() @@ -39,10 +39,10 @@ export class NativeDecryptService { if (this.initialized) return try { - // 1. 查找 DLL 路径 - this.dllPath = this.findDllPath() - if (!this.dllPath) { - this.initError = '未找到 wcdb_decrypt.dll' + // 1. 查找原生库路径 + this.nativeLibPath = this.findNativeLibraryPath() + if (!this.nativeLibPath) { + this.initError = `未找到 ${this.getNativeLibraryName()}` console.warn('[NativeDecrypt] ' + this.initError) return } @@ -56,11 +56,11 @@ export class NativeDecryptService { } console.log('[NativeDecrypt] 启动 Worker:', workerScript) - console.log('[NativeDecrypt] DLL 路径:', this.dllPath) + console.log('[NativeDecrypt] 原生库路径:', this.nativeLibPath) // 3. 启动 Worker 线程 this.worker = new Worker(workerScript, { - workerData: { dllPath: this.dllPath } + workerData: { nativeLibPath: this.nativeLibPath } }) // 4. 监听 Worker 消息 @@ -117,21 +117,42 @@ export class NativeDecryptService { } /** - * 查找 DLL 路径 + * 获取当前平台原生库文件名 */ - private findDllPath(): string | null { + private getNativeLibraryName(): string { + return process.platform === 'darwin' ? 'libwcdb_decrypt.dylib' : 'wcdb_decrypt.dll' + } + + /** + * 查找原生库路径 + */ + private findNativeLibraryPath(): string | null { + const libraryName = this.getNativeLibraryName() const candidates: string[] = [] if (app.isPackaged) { candidates.push( - path.join(process.resourcesPath, 'wcdb_decrypt.dll'), - path.join(process.resourcesPath, 'resources', 'wcdb_decrypt.dll'), - path.join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'wcdb_decrypt.dll') + path.join(process.resourcesPath, libraryName), + path.join(process.resourcesPath, 'resources', libraryName), + path.join(process.resourcesPath, 'app.asar.unpacked', 'resources', libraryName) ) + if (process.platform === 'darwin') { + candidates.push( + path.join(process.resourcesPath, 'resources', 'macos', libraryName), + path.join(process.resourcesPath, 'macos', libraryName), + path.join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'macos', libraryName) + ) + } } else { candidates.push( - path.join(app.getAppPath(), 'resources', 'wcdb_decrypt.dll'), - path.join(app.getAppPath(), 'native-dlls', 'wcdb_decrypt', 'build', 'bin', 'Release', 'wcdb_decrypt.dll') + path.join(app.getAppPath(), 'resources', libraryName), + path.join(app.getAppPath(), 'native-dlls', 'wcdb_decrypt', 'build', 'bin', 'Release', libraryName) ) + if (process.platform === 'darwin') { + candidates.push( + path.join(app.getAppPath(), 'resources', 'macos', libraryName), + path.join(app.getAppPath(), 'native-dlls', 'build_macos', libraryName) + ) + } } return candidates.find(p => fs.existsSync(p)) || null } diff --git a/electron/services/platformService.ts b/electron/services/platformService.ts new file mode 100644 index 0000000..aa95ac9 --- /dev/null +++ b/electron/services/platformService.ts @@ -0,0 +1,75 @@ +import { app } from 'electron' +import { dirname, join } from 'path' +import { existsSync } from 'fs' + +export interface RuntimePlatformInfo { + platform: NodeJS.Platform + arch: string +} + +export interface CachePathResult { + success: boolean + path: string + drive: string +} + +export function getRuntimePlatformInfo(): RuntimePlatformInfo { + return { + platform: process.platform, + arch: process.arch + } +} + +export function getDefaultCachePath(): string { + const documentsPath = app.getPath('documents') + + if (process.platform === 'darwin') { + return join(documentsPath, 'CipherTalkData') + } + + if (process.env.VITE_DEV_SERVER_URL) { + return join(documentsPath, 'CipherTalkData') + } + + const exePath = app.getPath('exe') + const installDir = dirname(exePath) + const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\\\') + + if (process.platform === 'win32') { + if (isOnCDrive) { + return join(documentsPath, 'CipherTalkData') + } + return join(installDir, 'CipherTalkData') + } + + return join(app.getPath('userData'), 'CipherTalkData') +} + +export function getBestCachePath(): CachePathResult { + if (process.platform !== 'win32') { + return { + success: true, + path: getDefaultCachePath(), + drive: 'default' + } + } + + const drives = ['D', 'E', 'F', 'C'] + + for (const drive of drives) { + const drivePath = `${drive}:\\` + if (existsSync(drivePath)) { + return { + success: true, + path: join(drivePath, 'CipherTalkDB'), + drive + } + } + } + + return { + success: true, + path: getDefaultCachePath(), + drive: 'default' + } +} diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 2ae5f4a..57b5f08 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -2,6 +2,7 @@ import { dirname, join } from 'path' import { existsSync, readdirSync, statSync, readFileSync, mkdirSync, createWriteStream } from 'fs' import { writeFile } from 'fs/promises' import { ConfigService } from './config' +import { getDefaultCachePath as getPlatformDefaultCachePath } from './platformService' import Database from 'better-sqlite3' import { app } from 'electron' import { Isaac64 } from './isaac64' @@ -72,21 +73,7 @@ class VideoService { } private getDefaultCachePath(): string { - if (process.env.VITE_DEV_SERVER_URL) { - const documentsPath = app.getPath('documents') - return join(documentsPath, 'CipherTalkData') - } - - const exePath = app.getPath('exe') - const installDir = dirname(exePath) - - const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\') - if (isOnCDrive) { - const documentsPath = app.getPath('documents') - return join(documentsPath, 'CipherTalkData') - } - - return join(installDir, 'CipherTalkData') + return getPlatformDefaultCachePath() } /** diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 13e8cc6..26b794d 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -1,4 +1,4 @@ -import { join, dirname } from 'path' +import { basename, join } from 'path' import { existsSync, readdirSync, statSync } from 'fs' import { app } from 'electron' @@ -6,33 +6,37 @@ export class WcdbService { private lib: any = null private koffi: any = null private initialized = false - private handle: number | null = null // 改为 number 类型 + private handle: number | null = null - // 函数引用 private wcdbInit: any = null private wcdbShutdown: any = null private wcdbOpenAccount: any = null private wcdbCloseAccount: any = null private wcdbFreeString: any = null - private wcdbGetSessions: any = null private wcdbGetLogs: any = null private wcdbGetSnsTimeline: any = null private wcdbExecQuery: any = null - /** - * 获取 DLL 路径 - */ - private getDllPath(): string { - const resourcesPath = app.isPackaged + private getLibraryPath(): string { + const baseDir = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') - return join(resourcesPath, 'wcdb_api.dll') + if (process.platform === 'darwin') { + return join(baseDir, 'macos', 'libwcdb_api.dylib') + } + + return join(baseDir, 'wcdb_api.dll') + } + + private getWindowsCoreLibraryPath(): string { + const baseDir = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + + return join(baseDir, 'WCDB.dll') } - /** - * 递归查找 session.db 文件 - */ private findSessionDb(dir: string, depth = 0): string | null { if (depth > 5) return null @@ -55,7 +59,9 @@ export class WcdbService { const found = this.findSessionDb(fullPath, depth + 1) if (found) return found } - } catch { } + } catch { + // ignore + } } } catch (e) { console.error('查找 session.db 失败:', e) @@ -64,128 +70,157 @@ export class WcdbService { return null } - /** - * 初始化 wcdb 库 - * 返回: { success: boolean, error?: string } - */ + 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 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 resolveAccountRoot(dbPath: string, wxid: string): string | null { + const normalizedDbPath = dbPath.replace(/[\\/]+$/, '') + const direct = join(normalizedDbPath, wxid) + if (existsSync(direct) && this.isAccountDir(direct)) { + return direct + } + + 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 + } + } + } catch { + // ignore + } + + return null + } + + private resolveDbStoragePath(dbPath: string, wxid: string): string | 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 dbStoragePath = join(accountRoot, 'db_storage') + return existsSync(dbStoragePath) ? dbStoragePath : null + } + private async initialize(): Promise<{ success: boolean; error?: string }> { if (this.initialized) return { success: true } try { this.koffi = require('koffi') - const dllPath = this.getDllPath() + const libraryPath = this.getLibraryPath() - if (!existsSync(dllPath)) { - const msg = `WCDB DLL 不存在: ${dllPath}` - console.error(msg) - return { success: false, error: msg } + if (!existsSync(libraryPath)) { + return { success: false, error: `WCDB 原生库不存在: ${libraryPath}` } } - // 关键修复:显式预加载依赖库 WCDB.dll - const wcdbCorePath = join(dirname(dllPath), 'WCDB.dll') - if (existsSync(wcdbCorePath)) { - try { - this.koffi.load(wcdbCorePath) - } catch (e: any) { - console.warn('预加载 WCDB.dll 失败:', e) - // 不要在这里返回失败,尝试继续加载主 DLL + if (process.platform === 'win32') { + const wcdbCorePath = this.getWindowsCoreLibraryPath() + if (existsSync(wcdbCorePath)) { + try { + this.koffi.load(wcdbCorePath) + } catch (e: any) { + console.warn('预加载 WCDB.dll 失败:', e.message || e) + } } - } else { - console.warn('预加载警告: WCDB.dll 未找到', wcdbCorePath) } - // 尝试加载主 DLL - try { - this.lib = this.koffi.load(dllPath) - } catch (e: any) { - const msg = `koffi.load(wcdb_api) 失败: ${e.message}` - console.error(msg) - return { success: false, error: msg } - } - - // 定义类型 - 使用与 C 接口完全匹配的签名 - // wcdb_status wcdb_init() + this.lib = this.koffi.load(libraryPath) this.wcdbInit = this.lib.func('int32 wcdb_init()') - - // wcdb_status wcdb_shutdown() this.wcdbShutdown = this.lib.func('int32 wcdb_shutdown()') - - // wcdb_status wcdb_open_account(const char* session_db_path, const char* hex_key, wcdb_handle* out_handle) this.wcdbOpenAccount = this.lib.func('int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)') - - // wcdb_status wcdb_close_account(wcdb_handle handle) this.wcdbCloseAccount = this.lib.func('int32 wcdb_close_account(int64 handle)') - - // void wcdb_free_string(char* ptr) this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)') - - // wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json) - this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)') - - // wcdb_status wcdb_get_logs(char** out_json) this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') - - // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 start_time, int32 end_time, char** out_json) this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)') - - // wcdb_status wcdb_exec_query(wcdb_handle handle, const char* db_kind, const char* db_path, const char* sql, char** out_json) this.wcdbExecQuery = this.lib.func('int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)') - // 初始化 const initResult = this.wcdbInit() if (initResult !== 0) { - const msg = `WCDB wcdb_init() 返回错误码: ${initResult}` - console.error(msg) - return { success: false, error: msg } + return { success: false, error: `wcdb_init() 返回错误码: ${initResult}` } } this.initialized = true return { success: true } } catch (e: any) { - console.error('WCDB 初始化异常:', e) - return { success: false, error: `初始化异常: ${e.message}` } + return { success: false, error: `WCDB 初始化异常: ${e.message}` } } } - /** - * 测试数据库连接 - */ async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { try { - if (!this.initialized) { - const initRes = await this.initialize() - if (!initRes.success) { - return { success: false, error: initRes.error || 'WCDB 初始化失败(未知原因)' } - } + const initRes = await this.initialize() + if (!initRes.success) { + return { success: false, error: initRes.error || 'WCDB 初始化失败' } } - // 构建 db_storage 目录路径 - const dbStoragePath = join(dbPath, wxid, 'db_storage') - - if (!existsSync(dbStoragePath)) { - return { success: false, error: `数据库目录不存在: ${dbStoragePath}` } + const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) + if (!dbStoragePath) { + return { success: false, error: `未找到账号目录或 db_storage: ${dbPath}` } } - // 递归查找 session.db const sessionDbPath = this.findSessionDb(dbStoragePath) - if (!sessionDbPath) { - return { success: false, error: `未找到 session.db 文件` } + return { success: false, error: '未找到 session.db 文件' } } - // 分配输出参数内存 - 使用 number 数组 const handleOut = [0] - const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) - if (result !== 0) { - // 获取 DLL 内部日志 await this.printLogs() - let errorMsg = '数据库打开失败' - if (result === -1) errorMsg = '参数错误' - else if (result === -2) errorMsg = '密钥错误' - else if (result === -3) errorMsg = '数据库打开失败' - return { success: false, error: `${errorMsg} (错误码: ${result})` } + return { success: false, error: this.mapStatusCode(result) } } const handle = handleOut[0] @@ -193,9 +228,7 @@ export class WcdbService { return { success: false, error: '无效的数据库句柄' } } - // 保存句柄,保持连接打开(不再关闭) this.handle = handle - return { success: true, sessionCount: 0 } } catch (e) { console.error('测试连接异常:', e) @@ -203,103 +236,37 @@ export class WcdbService { } } - /** - * 打印 DLL 内部日志(仅在出错时调用) - */ - private async printLogs(): Promise { - try { - if (!this.wcdbGetLogs) return - const outPtr = [null as any] - const result = this.wcdbGetLogs(outPtr) - if (result === 0 && outPtr[0]) { - try { - const jsonStr = this.koffi.decode(outPtr[0], 'char', -1) - console.error('WCDB 内部日志:', jsonStr) - this.wcdbFreeString(outPtr[0]) - } catch (e) { - // ignore - } - } - } catch (e) { - console.error('获取日志失败:', e) - } - } - - /** - * 打开数据库 - */ async open(dbPath: string, hexKey: string, wxid: string): Promise { - try { - if (!this.initialized) { - const initOk = await this.initialize() - if (!initOk) return false - } - - if (this.handle !== null) { - this.close() - } - - const dbStoragePath = join(dbPath, wxid, 'db_storage') - - if (!existsSync(dbStoragePath)) { - console.error('数据库目录不存在:', dbStoragePath) - return false - } - - const sessionDbPath = this.findSessionDb(dbStoragePath) - if (!sessionDbPath) { - console.error('未找到 session.db 文件') - return false - } - - const handleOut = [0] // 使用 number 而不是 BigInt - const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) - - if (result !== 0) { - console.error('打开数据库失败:', result) - return false - } - - const handle = handleOut[0] - if (handle <= 0) { - return false - } - - this.handle = handle - return true - } catch (e) { - console.error('打开数据库异常:', e) - return false - } + const result = await this.testConnection(dbPath, hexKey, wxid) + return result.success } - /** - * 关闭数据库 - * 注意:wcdb_close_account 可能导致崩溃,使用 shutdown 代替 - */ close(): void { - if (this.handle !== null || this.initialized) { + if (this.handle !== null && this.wcdbCloseAccount) { + try { + this.wcdbCloseAccount(this.handle) + } catch (e) { + console.error('关闭 WCDB 句柄失败:', e) + } + } + + if (this.initialized && this.wcdbShutdown) { try { - // 不调用 closeAccount,直接 shutdown this.wcdbShutdown() } catch (e) { - console.error('WCDB shutdown 出错:', e) + console.error('WCDB shutdown 失败:', e) } - this.handle = null - this.initialized = false } + + this.handle = null + this.initialized = false + this.lib = null } - /** - * 关闭服务(与 close 相同) - */ shutdown(): void { this.close() } - /** - * 获取朋友圈时间线 - */ async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { if (!this.initialized || this.handle === null) { return { success: false, error: 'WCDB 未初始化' } @@ -307,10 +274,7 @@ export class WcdbService { try { const outJson = [null] - - // 将 usernames 数组转换为 JSON 字符串 const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : '' - const result = this.wcdbGetSnsTimeline( this.handle, limit, @@ -323,27 +287,21 @@ export class WcdbService { ) if (result !== 0) { - return { success: false, error: `获取朋友圈失败 (错误码: ${result})` } + return { success: false, error: this.mapStatusCode(result) } } if (!outJson[0]) { return { success: true, timeline: [] } } - // 使用 -1 读取到 null 终止符 const jsonStr = this.koffi.decode(outJson[0], 'char', -1) this.wcdbFreeString(outJson[0]) - - const timeline = JSON.parse(jsonStr) - return { success: true, timeline } + return { success: true, timeline: JSON.parse(jsonStr) } } catch (e: any) { - return { success: false, error: e.message } + return { success: false, error: e.message || String(e) } } } - /** - * 执行原始 SQL 查询 - */ async execQuery(kind: string, path: string, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { if (!this.initialized || this.handle === null) { return { success: false, error: 'WCDB 未初始化' } @@ -352,38 +310,60 @@ export class WcdbService { try { const outJson = [null] const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outJson) - if (result !== 0 || !outJson[0]) { - return { success: false, error: `执行查询失败 (错误码: ${result})` } + return { success: false, error: this.mapStatusCode(result) } } const jsonStr = this.koffi.decode(outJson[0], 'char', -1) this.wcdbFreeString(outJson[0]) - - const rows = JSON.parse(jsonStr) - return { success: true, rows } + return { success: true, rows: JSON.parse(jsonStr) } } catch (e: any) { - return { success: false, error: e.message } + return { success: false, error: e.message || String(e) } } } - /** - * 解密朋友圈图片(使用纯 JS 实现,不依赖 DLL) - */ - async decryptSnsImage(encryptedData: Buffer, key: string): Promise { - // 朋友圈图片解密暂不支持,返回原始数据 - console.warn('[wcdbService] 朋友圈图片解密暂不支持,DLL 未提供该功能') + async decryptSnsImage(encryptedData: Buffer, _key: string): Promise { return encryptedData } - /** - * 解密朋友圈视频(使用纯 JS 实现,不依赖 DLL) - */ - async decryptSnsVideo(encryptedData: Buffer, key: string): Promise { - // 朋友圈视频解密暂不支持,返回原始数据 - console.warn('[wcdbService] 朋友圈视频解密暂不支持,DLL 未提供该功能') + async decryptSnsVideo(encryptedData: Buffer, _key: string): Promise { return encryptedData } + + private async printLogs(): Promise { + try { + 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]) + } + } catch (e) { + console.error('获取 WCDB 日志失败:', e) + } + } + + private mapStatusCode(code: number): string { + switch (code) { + case 0: + return '成功' + case -1: + return '参数错误' + case -2: + return '密钥错误' + case -3: + case -4: + return '数据库打开失败' + case -5: + return '查询执行失败' + case -6: + return 'WCDB 尚未初始化' + default: + return `WCDB 错误码: ${code}` + } + } } export const wcdbService = new WcdbService() diff --git a/electron/services/windowsHelloService.ts b/electron/services/windowsHelloService.ts index a460794..61b8ae6 100644 --- a/electron/services/windowsHelloService.ts +++ b/electron/services/windowsHelloService.ts @@ -45,6 +45,7 @@ class WindowsHelloService { */ private init(): boolean { if (this.lib) return true + if (process.platform !== 'win32') return false try { const koffi = require('koffi') @@ -87,6 +88,7 @@ class WindowsHelloService { * 检查 Windows Hello 是否可用 */ isAvailable(): boolean { + if (process.platform !== 'win32') return false if (!this.init()) return false try { @@ -104,6 +106,14 @@ class WindowsHelloService { * @returns 验证结果 */ verify(message: string = 'CipherTalk 需要验证您的身份'): { success: boolean; result: WindowsHelloResult; error?: string } { + if (process.platform !== 'win32') { + return { + success: false, + result: WindowsHelloResult.DEVICE_NOT_PRESENT, + error: '当前平台不支持 Windows Hello' + } + } + if (!this.init()) { return { success: false, diff --git a/electron/services/wxKeyServiceMac.ts b/electron/services/wxKeyServiceMac.ts new file mode 100644 index 0000000..14e13bb --- /dev/null +++ b/electron/services/wxKeyServiceMac.ts @@ -0,0 +1,1123 @@ +import { app } from 'electron' +import { basename, dirname, join } from 'path' +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { execFile, execSync, spawn } from 'child_process' +import { promisify } from 'util' +import crypto from 'crypto' +import { homedir } from 'os' + +const execFileAsync = promisify(execFile) + +type DbKeyResult = { + success: boolean + key?: string + error?: string + logs?: string[] +} + +type ImageKeyResult = { + success: boolean + xorKey?: number + aesKey?: string + error?: string +} + +export class WxKeyServiceMac { + private koffi: any = null + private lib: any = null + private initialized = false + private GetDbKey: any = null + private ListWeChatProcesses: any = null + private libSystem: any = null + private machTaskSelf: any = null + private taskForPid: any = null + private machVmRegion: any = null + private machVmReadOverwrite: any = null + private machPortDeallocate: any = null + private needsElevation = false + + private getResourceDirs(): string[] { + if (app.isPackaged) { + return [ + join(process.resourcesPath, 'resources', 'macos'), + join(process.resourcesPath, 'macos') + ] + } + + return [ + join(app.getAppPath(), 'resources', 'macos'), + join(process.cwd(), 'resources', 'macos') + ] + } + + private resolveResource(name: string): string { + for (const dir of this.getResourceDirs()) { + const candidate = join(dir, name) + if (existsSync(candidate)) return candidate + } + + throw new Error(`${name} not found`) + } + + private getHelperPath(): string { + if (process.env.WX_KEY_HELPER_PATH && existsSync(process.env.WX_KEY_HELPER_PATH)) { + return process.env.WX_KEY_HELPER_PATH + } + return this.resolveResource('xkey_helper') + } + + private getImageScanHelperPath(): string { + if (process.env.IMAGE_SCAN_HELPER_PATH && existsSync(process.env.IMAGE_SCAN_HELPER_PATH)) { + return process.env.IMAGE_SCAN_HELPER_PATH + } + return this.resolveResource('image_scan_helper') + } + + private getDylibPath(): string { + if (process.env.WX_KEY_DYLIB_PATH && existsSync(process.env.WX_KEY_DYLIB_PATH)) { + return process.env.WX_KEY_DYLIB_PATH + } + return this.resolveResource('libwx_key.dylib') + } + + 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 + } catch (e) { + console.error('[WxKeyServiceMac] 初始化失败:', e) + return false + } + } + + async checkSipStatus(): Promise<{ enabled: boolean; error?: string }> { + try { + const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status']) + return { enabled: stdout.toLowerCase().includes('enabled') } + } catch (e: any) { + return { enabled: false, error: e.message } + } + } + + isWeChatRunning(): boolean { + return this.getWeChatPid() !== null + } + + getWeChatPid(): number | null { + try { + const exact = execSync('/usr/bin/pgrep -x WeChat', { encoding: 'utf8' }) + const ids = exact.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0) + if (ids.length > 0) return Math.max(...ids) + } catch { + // ignore + } + + try { + const fuzzy = execSync('/usr/bin/pgrep -f WeChat.app/Contents/MacOS/WeChat', { encoding: 'utf8' }) + const ids = fuzzy.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0) + if (ids.length > 0) return Math.max(...ids) + } catch { + // ignore + } + + try { + const output = execSync('/bin/ps -A -o pid,comm,command', { encoding: 'utf8' }) + const lines = output.split(/\r?\n/).slice(1) + const candidates: number[] = [] + + for (const line of lines) { + const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/) + if (!match) continue + + const pid = parseInt(match[1], 10) + const comm = match[2] + const command = match[3] + const isMain = comm === 'WeChat' || command.includes('/Contents/MacOS/WeChat') + const isHelper = command.includes('WeChatAppEx') || command.includes('Helper') || command.includes('crashpad_handler') + if (isMain && !isHelper) { + candidates.push(pid) + } + } + + if (candidates.length > 0) { + return Math.max(...candidates) + } + } catch { + // ignore + } + + return null + } + + killWeChat(): boolean { + try { + execSync('/usr/bin/pkill -x WeChat', { stdio: 'ignore' }) + return true + } catch { + return false + } + } + + async launchWeChat(customPath?: string): Promise { + try { + if (customPath && existsSync(customPath)) { + await execFileAsync('/usr/bin/open', [customPath]) + } else { + await execFileAsync('/usr/bin/open', ['-a', 'WeChat']) + } + await new Promise(resolve => setTimeout(resolve, 1500)) + return this.isWeChatRunning() + } catch { + return false + } + } + + async waitForWeChatWindow(maxWaitSeconds = 15): Promise { + for (let i = 0; i < maxWaitSeconds * 2; i++) { + if (this.isWeChatRunning()) { + return true + } + await new Promise(resolve => setTimeout(resolve, 500)) + } + return false + } + + async autoGetDbKey( + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void + ): Promise { + try { + const sipStatus = await this.checkSipStatus() + if (sipStatus.enabled) { + return { + success: false, + error: 'SIP 已开启,无法抓取 macOS 微信数据库密钥。请先关闭 SIP 后重试。' + } + } + + 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) + } catch (e: any) { + const msg = String(e?.message || e) + if (msg.includes('(-128)') || msg.includes('User canceled')) { + return { success: false, error: '已取消管理员授权' } + } + throw e + } + + if (!parsed.success) { + return { + success: false, + error: this.mapDbKeyErrorMessage(parsed.code, parsed.detail) + } + } + + onStatus?.('密钥获取成功', 1) + return { success: true, key: parsed.key } + } catch (e: any) { + console.error('[WxKeyServiceMac] 获取密钥失败:', e) + onStatus?.(`获取失败: ${e.message}`, 2) + return { success: false, error: e.message || String(e) } + } + } + + private async getDbKeyByHelperElevated( + timeoutMs: number, + onStatus?: (message: string, level: number) => void + ): Promise { + const helperPath = this.getHelperPath() + const waitMs = Math.max(timeoutMs, 30_000) + const timeoutSec = Math.ceil(waitMs / 1000) + 30 + const pid = this.getWeChatPid() + + if (!pid) { + throw new Error('未找到微信主进程') + } + + const scriptLines = [ + `set helperPath to ${JSON.stringify(helperPath)}`, + `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + `set timeoutSec to ${timeoutSec}`, + 'try', + 'with timeout of timeoutSec seconds', + 'set outText to do shell script (cmd & " 2>&1") with administrator privileges', + 'end timeout', + 'return "WF_OK::" & outText', + 'on error errMsg number errNum partial result pr', + 'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)', + 'end try' + ] + + onStatus?.(`已找到微信进程 PID=${pid},正在请求管理员授权...`, 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 返回空输出') + } + + 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 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 + } + } + + throw new Error(`elevated helper returned invalid output: ${normalizedOutput}`) + } + + private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } { + const text = typeof raw === 'string' ? raw.trim() : '' + 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 mapDbKeyErrorMessage(code?: string, detail?: string): string { + if (code === 'PROCESS_NOT_FOUND') return '微信主进程未运行' + if (code === 'ATTACH_FAILED') return `无法附加微信进程 (${detail || 'operation not permitted'})` + if (code === 'SCAN_FAILED') return `未定位到目标函数 (${detail || 'sink pattern not found'})` + if (code === 'HOOK_FAILED') return `已定位目标,但断点等待超时 (${detail || 'hook timeout'})` + if (code === 'HOOK_TARGET_ONLY') return `仅定位到目标地址,尚未捕获到最终 DbKey (${detail || ''})` + return detail ? `${code || 'UNKNOWN'}: ${detail}` : '未知错误' + } + + async autoGetImageKey( + accountPath?: string, + onStatus?: (message: string) => void, + wxid?: string + ): Promise { + try { + onStatus?.('正在从 kvcomm 缓存收集密钥码...') + const codes = this.collectKvcommCodes(accountPath) + if (codes.length === 0) { + return { success: false, error: '未找到有效的 kvcomm 密钥码' } + } + + const wxidCandidates = this.collectWxidCandidates(accountPath, wxid) + const accountPathCandidates = this.collectAccountPathCandidates(accountPath) + + if (accountPathCandidates.length > 0) { + onStatus?.(`正在校验候选账号(${wxidCandidates.length} 个)...`) + for (const candidateAccountPath of accountPathCandidates) { + if (!existsSync(candidateAccountPath)) continue + const template = await this.findTemplateData(candidateAccountPath, 32) + if (!template.ciphertext) continue + + const orderedWxids: string[] = [] + this.pushAccountIdCandidates(orderedWxids, basename(candidateAccountPath)) + for (const candidate of wxidCandidates) { + this.pushAccountIdCandidates(orderedWxids, candidate) + } + + for (const candidateWxid of orderedWxids) { + for (const code of codes) { + const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) + if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue + onStatus?.(`图片密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) + return { success: true, xorKey, aesKey } + } + } + } + + return { + success: false, + error: 'kvcomm 密钥码与当前账号目录未匹配,请确认账号目录后重试。' + } + } + + const fallbackWxid = wxidCandidates[0] + const fallbackCode = codes[0] + const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) + onStatus?.(`图片密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) + return { success: true, xorKey, aesKey } + } catch (e: any) { + return { success: false, error: `自动获取图片密钥失败: ${e.message}` } + } + } + + async autoGetImageKeyByMemoryScan( + userDir: string, + onProgress?: (message: string) => void + ): Promise { + try { + 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')},正在查找微信进程...`) + + const deadline = Date.now() + 60_000 + let scanCount = 0 + let lastPid: number | null = null + + while (Date.now() < deadline) { + const pid = this.getWeChatPid() + if (!pid) { + onProgress?.('暂未检测到微信主进程,请先启动微信...') + await new Promise(resolve => setTimeout(resolve, 2000)) + continue + } + + if (lastPid !== pid) { + lastPid = pid + onProgress?.(`已找到微信进程 PID=${pid},开始扫描内存...`) + } + + scanCount += 1 + onProgress?.(`第 ${scanCount} 次扫描内存,请保持图片已在微信中打开...`) + const aesKey = await this.scanMemoryForAesKey(pid, ciphertext, onProgress) + if (aesKey) { + onProgress?.('图片密钥获取成功') + return { success: true, xorKey, aesKey } + } + + await new Promise(resolve => setTimeout(resolve, 5000)) + } + + return { success: false, error: '60 秒内未找到 AES 密钥。' } + } catch (e: any) { + return { success: false, error: `内存扫描失败: ${e.message}` } + } + } + + detectCurrentAccount(dbPath?: string, maxTimeDiffMinutes: number = 5): { wxid: string; dbPath: string } | null { + if (!dbPath || !existsSync(dbPath)) { + return null + } + + const accountDirs = this.findAccountDirectories(dbPath) + if (accountDirs.length === 0) { + return null + } + + const now = Date.now() + const maxDiffMs = maxTimeDiffMinutes * 60 * 1000 + let bestMatch: { wxid: string; dbPath: string; diff: number } | null = null + let fallback: { wxid: string; dbPath: string; diff: number } | null = null + + for (const accountDir of accountDirs) { + const modifiedTime = this.getAccountModifiedTime(accountDir) + const diff = Math.abs(now - modifiedTime) + const wxid = basename(accountDir) + + if (diff <= maxDiffMs && (!bestMatch || diff < bestMatch.diff)) { + bestMatch = { wxid, dbPath: accountDir, diff } + } + if (!fallback || diff < fallback.diff) { + fallback = { wxid, dbPath: accountDir, diff } + } + } + + if (bestMatch) { + return { wxid: bestMatch.wxid, dbPath: bestMatch.dbPath } + } + + if (fallback && (accountDirs.length === 1 || fallback.diff <= 24 * 60 * 60 * 1000)) { + return { wxid: fallback.wxid, dbPath: fallback.dbPath } + } + + return null + } + + private findAccountDirectories(rootOrAccountPath: string): string[] { + if (!existsSync(rootOrAccountPath)) return [] + if (this.isAccountDirPath(rootOrAccountPath)) return [rootOrAccountPath] + + const result: string[] = [] + try { + for (const entry of readdirSync(rootOrAccountPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = join(rootOrAccountPath, entry.name) + if (!this.isReasonableAccountId(entry.name)) continue + if (this.isAccountDirPath(entryPath)) { + result.push(entryPath) + } + } + } catch { + // ignore + } + return result + } + + private getAccountModifiedTime(accountDir: string): number { + try { + const accountStat = statSync(accountDir) + let latest = accountStat.mtimeMs + const candidates = [ + join(accountDir, 'db_storage'), + join(accountDir, 'FileStorage', 'Image'), + join(accountDir, 'FileStorage', 'Image2'), + join(accountDir, 'msg', 'attach') + ] + for (const candidate of candidates) { + if (existsSync(candidate)) { + latest = Math.max(latest, statSync(candidate).mtimeMs) + } + } + return latest + } catch { + return 0 + } + } + + private normalizeAccountId(value: string): string { + const trimmed = String(value || '').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 isIgnoredAccountName(value: string): boolean { + const lowered = String(value || '').trim().toLowerCase() + if (!lowered) return true + return lowered === 'xwechat_files' || + lowered === 'all_users' || + lowered === 'backup' || + lowered === 'wmpf' || + lowered === 'app_data' + } + + private isReasonableAccountId(value: string): boolean { + const trimmed = String(value || '').trim() + if (!trimmed) return false + if (trimmed.includes('/') || trimmed.includes('\\')) return false + return !this.isIgnoredAccountName(trimmed) + } + + private isAccountDirPath(entryPath: string): boolean { + return existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'msg')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + } + + private resolveXwechatRootFromPath(accountPath?: string): string | null { + const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') + if (!normalized) return null + + const oldMarker = '/xwechat_files' + const oldIndex = normalized.indexOf(oldMarker) + if (oldIndex >= 0) { + return normalized.slice(0, oldIndex + oldMarker.length) + } + + const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/) + if (newMarkerMatch) { + return newMarkerMatch[1] + } + + return null + } + + private pushAccountIdCandidates(candidates: string[], value?: string): void { + const raw = String(value || '').trim() + if (!this.isReasonableAccountId(raw)) return + + const pushUnique = (item: string) => { + const trimmed = String(item || '').trim() + if (!trimmed || candidates.includes(trimmed)) return + candidates.push(trimmed) + } + + pushUnique(raw) + const normalized = this.normalizeAccountId(raw) + if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) { + pushUnique(normalized) + } + } + + private cleanWxid(wxid: string): string { + return this.normalizeAccountId(wxid) + } + + private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { + const cleanedWxid = this.cleanWxid(wxid) + const xorKey = code & 0xFF + const dataToHash = code.toString() + cleanedWxid + const aesKey = crypto.createHash('md5').update(dataToHash).digest('hex').substring(0, 16) + return { xorKey, aesKey } + } + + private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] { + const candidates: string[] = [] + this.pushAccountIdCandidates(candidates, wxidParam) + + if (accountPath) { + const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') + this.pushAccountIdCandidates(candidates, basename(normalized)) + + const root = this.resolveXwechatRootFromPath(accountPath) + if (root && existsSync(root)) { + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = join(root, entry.name) + if (this.isAccountDirPath(entryPath)) { + this.pushAccountIdCandidates(candidates, entry.name) + } + } + } catch { + // ignore + } + } + } + + return candidates.length > 0 ? candidates : ['unknown'] + } + + private collectAccountPathCandidates(accountPath?: string): string[] { + const candidates: string[] = [] + const pushUnique = (value?: string) => { + const item = String(value || '').trim() + if (!item || candidates.includes(item)) return + candidates.push(item) + } + + if (accountPath) pushUnique(accountPath) + + if (accountPath) { + const root = this.resolveXwechatRootFromPath(accountPath) + if (root && existsSync(root)) { + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = join(root, entry.name) + if (!this.isReasonableAccountId(entry.name)) continue + if (this.isAccountDirPath(entryPath)) { + pushUnique(entryPath) + } + } + } catch { + // ignore + } + } + } + + return candidates + } + + private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { + try { + const keyBytes = Buffer.from(aesKey, 'ascii').subarray(0, 16) + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes, null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return ( + (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) || + (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) || + (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) || + (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) || + (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) + ) + } catch { + return false + } + } + + private collectKvcommCodes(accountPath?: string): number[] { + const codeSet = new Set() + const pattern = /^key_(\d+)_.+\.statistic$/i + + for (const kvcommDir of this.getKvcommCandidates(accountPath)) { + if (!existsSync(kvcommDir)) continue + try { + for (const file of readdirSync(kvcommDir)) { + const match = file.match(pattern) + if (!match) continue + const code = Number(match[1]) + if (Number.isFinite(code) && code > 0 && code <= 0xFFFFFFFF) { + codeSet.add(code) + } + } + } catch { + // ignore + } + } + + return Array.from(codeSet) + } + + private getKvcommCandidates(accountPath?: string): string[] { + const home = homedir() + const candidates = new Set([ + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'app_data', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'xwechat', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat', 'net', 'kvcomm') + ]) + + if (accountPath) { + const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') + const oldMarker = '/xwechat_files' + const oldIndex = normalized.indexOf(oldMarker) + if (oldIndex >= 0) { + candidates.add(`${normalized.slice(0, oldIndex)}/app_data/net/kvcomm`) + } + + const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/) + if (newMarkerMatch) { + const versionBase = newMarkerMatch[1] + candidates.add(`${versionBase}/net/kvcomm`) + candidates.add(`${versionBase.replace(/\/[^\/]+$/, '')}/net/kvcomm`) + } + + let cursor = accountPath + for (let i = 0; i < 6; i++) { + candidates.add(join(cursor, 'net', 'kvcomm')) + const next = dirname(cursor) + if (next === cursor) break + cursor = next + } + } + + return Array.from(candidates) + } + + private async findTemplateData(userDir: string, limit = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const magic = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + const files: string[] = [] + + const collect = (dir: string) => { + if (files.length >= limit) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (files.length >= limit) break + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + collect(fullPath) + } else if (entry.isFile() && entry.name.endsWith('_t.dat')) { + files.push(fullPath) + } + } + } catch { + // ignore + } + } + + collect(userDir) + files.sort((a, b) => { + try { + return statSync(b).mtimeMs - statSync(a).mtimeMs + } catch { + return 0 + } + }) + + let ciphertext: Buffer | null = null + const tailCounts = new Map() + + for (const file of files.slice(0, 32)) { + try { + const data = readFileSync(file) + if (data.length < 8 || !data.subarray(0, 6).equals(magic)) continue + + if (data.length >= 0x1F && !ciphertext) { + ciphertext = data.subarray(0x0F, 0x1F) + } + + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts.set(key, (tailCounts.get(key) || 0) + 1) + } catch { + // ignore + } + } + + let xorKey: number | null = null + let maxCount = 0 + + for (const [key, count] of tailCounts.entries()) { + if (count <= maxCount) continue + const [x, y] = key.split('_').map(Number) + const candidate = x ^ 0xFF + if (candidate === (y ^ 0xD9)) { + maxCount = count + xorKey = candidate + } + } + + return { ciphertext, xorKey } + } + + private ensureMachApis(): boolean { + if (this.machTaskSelf && this.taskForPid && this.machVmRegion && this.machVmReadOverwrite) { + return true + } + + try { + if (!this.koffi) { + this.koffi = require('koffi') + } + + this.libSystem = this.koffi.load('/usr/lib/libSystem.B.dylib') + this.machTaskSelf = this.libSystem.func('mach_task_self', 'uint32', []) + this.taskForPid = this.libSystem.func('task_for_pid', 'int', ['uint32', 'int', this.koffi.out('uint32*')]) + this.machVmRegion = this.libSystem.func('mach_vm_region', 'int', [ + 'uint32', + this.koffi.out('uint64*'), + this.koffi.out('uint64*'), + 'int', + 'void*', + this.koffi.out('uint32*'), + this.koffi.out('uint32*') + ]) + this.machVmReadOverwrite = this.libSystem.func('mach_vm_read_overwrite', 'int', [ + 'uint32', + 'uint64', + 'uint64', + 'void*', + this.koffi.out('uint64*') + ]) + this.machPortDeallocate = this.libSystem.func('mach_port_deallocate', 'int', ['uint32', 'uint32']) + return true + } catch (e) { + console.error('[WxKeyServiceMac] 初始化 Mach API 失败:', e) + return false + } + } + + private async scanMemoryForAesKey( + pid: number, + ciphertext: Buffer, + onProgress?: (message: string) => void + ): Promise { + try { + const helperPath = this.getImageScanHelperPath() + const ciphertextHex = ciphertext.toString('hex') + + if (!this.needsElevation) { + const direct = await this.spawnScanHelper(helperPath, pid, ciphertextHex, false) + if (direct.key) return direct.key + if (direct.permissionError) { + this.needsElevation = true + onProgress?.('需要管理员权限,正在切换提权扫描...') + } + } + + if (this.needsElevation) { + const elevated = await this.spawnScanHelper(helperPath, pid, ciphertextHex, true) + if (elevated.key) return elevated.key + } + } catch (e: any) { + console.warn('[WxKeyServiceMac] image_scan_helper 不可用,回退 Mach API:', e.message) + } + + if (!this.ensureMachApis()) { + return null + } + + const VM_PROT_READ = 0x1 + const VM_PROT_WRITE = 0x2 + const VM_REGION_BASIC_INFO_64 = 9 + const VM_REGION_BASIC_INFO_COUNT_64 = 9 + const KERN_SUCCESS = 0 + const MAX_REGION_SIZE = 50 * 1024 * 1024 + const CHUNK = 4 * 1024 * 1024 + const OVERLAP = 65 + + const selfTask = this.machTaskSelf() + const taskBuf = Buffer.alloc(4) + const attachKr = this.taskForPid(selfTask, pid, taskBuf) + const task = taskBuf.readUInt32LE(0) + if (attachKr !== KERN_SUCCESS || !task) { + return null + } + + try { + const regions: Array<[number, number]> = [] + let address = 0 + + while (address < 0x7FFFFFFFFFFF) { + const addrBuf = Buffer.alloc(8) + addrBuf.writeBigUInt64LE(BigInt(address), 0) + const sizeBuf = Buffer.alloc(8) + const infoBuf = Buffer.alloc(64) + const countBuf = Buffer.alloc(4) + countBuf.writeUInt32LE(VM_REGION_BASIC_INFO_COUNT_64, 0) + const objectBuf = Buffer.alloc(4) + + const kr = this.machVmRegion(task, addrBuf, sizeBuf, VM_REGION_BASIC_INFO_64, infoBuf, countBuf, objectBuf) + if (kr !== KERN_SUCCESS) break + + const base = Number(addrBuf.readBigUInt64LE(0)) + const size = Number(sizeBuf.readBigUInt64LE(0)) + const protection = infoBuf.readInt32LE(0) + const objectName = objectBuf.readUInt32LE(0) + if (objectName) { + try { this.machPortDeallocate(selfTask, objectName) } catch { } + } + + if ((protection & VM_PROT_READ) !== 0 && (protection & VM_PROT_WRITE) !== 0 && size > 0 && size <= MAX_REGION_SIZE) { + regions.push([base, size]) + } + + const next = base + size + if (next <= address) break + address = next + } + + const totalMB = regions.reduce((sum, [, size]) => sum + size, 0) / 1024 / 1024 + onProgress?.(`扫描 ${regions.length} 个内存区域 (${totalMB.toFixed(0)} MB)...`) + + for (let regionIndex = 0; regionIndex < regions.length; regionIndex++) { + const [base, size] = regions[regionIndex] + if (regionIndex % 20 === 0) { + onProgress?.(`扫描进度 ${regionIndex}/${regions.length}...`) + await new Promise(resolve => setTimeout(resolve, 1)) + } + + let offset = 0 + let trailing: Buffer | null = null + + while (offset < size) { + const chunkSize = Math.min(CHUNK, size - offset) + const chunk = Buffer.alloc(chunkSize) + const outSizeBuf = Buffer.alloc(8) + const kr = this.machVmReadOverwrite(task, base + offset, chunkSize, chunk, outSizeBuf) + const bytesRead = Number(outSizeBuf.readBigUInt64LE(0)) + offset += chunkSize + + if (kr !== KERN_SUCCESS || bytesRead <= 0) { + trailing = null + continue + } + + const current = chunk.subarray(0, bytesRead) + const data = trailing ? Buffer.concat([trailing, current]) : current + const key = this.searchAsciiKey(data, ciphertext) || this.searchUtf16Key(data, ciphertext) || this.searchAny16Key(data, ciphertext) + if (key) return key + trailing = data.subarray(Math.max(0, data.length - OVERLAP)) + } + } + } finally { + try { this.machPortDeallocate(selfTask, task) } catch { } + } + + return null + } + + private spawnScanHelper( + helperPath: string, + pid: number, + ciphertextHex: string, + elevated: boolean + ): Promise<{ key: string | null; permissionError: boolean }> { + return new Promise((resolve, reject) => { + let child: any + + if (elevated) { + const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}` + child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + } else { + child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] }) + } + + let stdout = '' + let stderr = '' + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.on('error', reject) + child.on('close', () => { + const permissionError = !elevated && stderr.includes('task_for_pid failed') + try { + const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean) + const last = lines[lines.length - 1] + if (!last) { + resolve({ key: null, permissionError }) + return + } + const payload = JSON.parse(last) + resolve({ + key: payload?.success && payload?.aesKey ? payload.aesKey : null, + permissionError + }) + } catch { + resolve({ key: null, permissionError }) + } + }) + + setTimeout(() => { + try { child.kill('SIGTERM') } catch { } + }, elevated ? 60_000 : 30_000) + }) + } + + private searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 34; i++) { + if (this.isAlphaNum(data[i])) continue + let valid = true + for (let j = 1; j <= 32; j++) { + if (!this.isAlphaNum(data[i + j])) { + valid = false + break + } + } + if (!valid) continue + if (i + 33 < data.length && this.isAlphaNum(data[i + 33])) continue + const keyBytes = data.subarray(i + 1, i + 33) + if (this.verifyAesKey(keyBytes, ciphertext)) { + return keyBytes.toString('ascii').substring(0, 16) + } + } + return null + } + + private searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 65; i++) { + let valid = true + for (let j = 0; j < 32; j++) { + if (data[i + j * 2 + 1] !== 0x00 || !this.isAlphaNum(data[i + j * 2])) { + valid = false + break + } + } + if (!valid) continue + + const keyBytes = Buffer.alloc(32) + for (let j = 0; j < 32; j++) { + keyBytes[j] = data[i + j * 2] + } + if (this.verifyAesKey(keyBytes, ciphertext)) { + return keyBytes.toString('ascii').substring(0, 16) + } + } + return null + } + + private searchAny16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i + 16 <= data.length; i++) { + const keyBytes = data.subarray(i, i + 16) + if (!this.verifyAesKey16Raw(keyBytes, ciphertext)) continue + if (!this.isMostlyPrintableAscii(keyBytes)) continue + return keyBytes.toString('ascii') + } + return null + } + + private isAlphaNum(byte: number): boolean { + return (byte >= 0x61 && byte <= 0x7A) || (byte >= 0x41 && byte <= 0x5A) || (byte >= 0x30 && byte <= 0x39) + } + + private verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return ( + (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) || + (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) || + (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) || + (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) || + (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) + ) + } catch { + return false + } + } + + private verifyAesKey16Raw(keyBytes: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes, null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return ( + (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) || + (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) || + (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) || + (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) || + (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) + ) + } catch { + return false + } + } + + private isMostlyPrintableAscii(keyBytes: Buffer): boolean { + let printable = 0 + for (const byte of keyBytes) { + if (byte >= 0x20 && byte <= 0x7E) { + printable += 1 + } + } + return printable >= 14 + } + + dispose(): void { + this.lib = null + this.initialized = false + this.GetDbKey = null + this.ListWeChatProcesses = null + this.libSystem = null + this.machTaskSelf = null + this.taskForPid = null + this.machVmRegion = null + this.machVmReadOverwrite = null + this.machPortDeallocate = null + } +} + +export const wxKeyServiceMac = new WxKeyServiceMac() diff --git a/electron/workers/decryptWorker.js b/electron/workers/decryptWorker.js index 10e01c5..e4a61aa 100644 --- a/electron/workers/decryptWorker.js +++ b/electron/workers/decryptWorker.js @@ -3,17 +3,17 @@ const path = require('path') const fs = require('fs') const koffi = require('koffi') -// 从 workerData 获取 DLL 路径 -const { dllPath } = workerData +// 从 workerData 获取原生库路径 +const { nativeLibPath } = workerData -if (!dllPath || !fs.existsSync(dllPath)) { - parentPort?.postMessage({ type: 'error', error: 'DLL path not found: ' + dllPath }) +if (!nativeLibPath || !fs.existsSync(nativeLibPath)) { + parentPort?.postMessage({ type: 'error', error: 'Native library path not found: ' + nativeLibPath }) process.exit(1) } try { - // 加载 DLL - const lib = koffi.load(dllPath) + // 加载原生库 + const lib = koffi.load(nativeLibPath) // 定义回调类型 const ProgressCallback = koffi.proto('void ProgressCallback(int current, int total)') diff --git a/package.json b/package.json index 21acff7..ce9e8ed 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,13 @@ "target": "nsis", "requestedExecutionLevel": "asInvoker" }, + "mac": { + "category": "public.app-category.utilities", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "resources/macos/entitlements.mac.plist", + "entitlementsInherit": "resources/macos/entitlements.mac.plist" + }, "nsis": { "differentialPackage": false, "oneClick": false, diff --git a/resources/macos/entitlements.mac.plist b/resources/macos/entitlements.mac.plist new file mode 100644 index 0000000..02af842 --- /dev/null +++ b/resources/macos/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/resources/macos/image_scan_entitlements.plist b/resources/macos/image_scan_entitlements.plist new file mode 100644 index 0000000..023065e --- /dev/null +++ b/resources/macos/image_scan_entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.debugger + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6092081..9e87d12 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -149,10 +149,15 @@ function SettingsPage() { const [isLoadingLogContent, setIsLoadingLogContent] = useState(false) const [logSize, setLogSize] = useState(0) const [currentLogLevel, setCurrentLogLevel] = useState('WARN') + const [platformInfo, setPlatformInfo] = useState<{ platform: string; arch: string }>({ + platform: 'win32', + arch: 'x64' + }) // 配置变化状态 const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const [initialConfig, setInitialConfig] = useState(null) + const isMac = platformInfo.platform === 'darwin' useEffect(() => { loadConfig() @@ -160,6 +165,9 @@ function SettingsPage() { loadAppVersion() loadCacheSize() loadLogFiles() + void window.electronAPI.app.getPlatformInfo().then(setPlatformInfo).catch(() => { + // ignore + }) }, []) const loadConfig = async () => { @@ -577,9 +585,58 @@ function SettingsPage() { const handleGetKey = async () => { if (isGettingKey) return setIsGettingKey(true) - setKeyStatus('正在检查微信进程...') + setKeyStatus(isMac ? '正在准备 macOS helper...' : '正在检查微信进程...') try { + if (isMac) { + const removeListener = window.electronAPI.wxKey.onStatus(({ status }) => { + setKeyStatus(status) + }) + + const result = await window.electronAPI.wxKey.startGetKey() + removeListener() + + if (result.success && result.key) { + setDecryptKey(result.key) + await configService.setDecryptKey(result.key) + + setKeyStatus('正在检测当前登录账号...') + + let accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 10) + if (!accountInfo) { + accountInfo = await window.electronAPI.wxKey.detectCurrentAccount(dbPath, 60) + } + + if (accountInfo) { + setWxid(accountInfo.wxid) + setIsAccountVerified(false) + await configService.setMyWxid(accountInfo.wxid) + showMessage(`密钥获取成功!已识别候选账号: ${accountInfo.wxid},请继续验证目录。`, true) + } else { + const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) + setWxidOptions(wxids) + setIsAccountVerified(false) + + if (wxids.length === 1) { + setWxid(wxids[0]) + showMessage('密钥获取成功,已识别到 1 个候选账号目录,请继续验证。', true) + } else if (wxids.length > 1) { + setShowWxidDropdown(true) + showMessage(`密钥获取成功,识别到 ${wxids.length} 个候选账号目录,请选择后验证。`, true) + } else { + showMessage('密钥获取成功,请手动填写或扫描账号目录后继续验证。', true) + } + } + + setKeyStatus('') + } else { + showMessage(result.error || '获取密钥失败', false) + setKeyStatus('') + } + + return + } + const isRunning = await window.electronAPI.wxKey.isWeChatRunning() if (isRunning) { const shouldKill = window.confirm('检测到微信正在运行,需要重启微信才能获取密钥。\n是否关闭当前微信?') @@ -1210,7 +1267,7 @@ function SettingsPage() {
- 64位十六进制密钥 + {isMac ? '64位十六进制 DbKey,macOS 通过 helper + 断点捕获获取' : '64位十六进制密钥'}
setDecryptKey(e.target.value)} /> {isGettingKey && }
+ + {isMac + ? 'macOS 要求先关闭 SIP;点击后会请求管理员授权,并在微信访问数据库时返回最终 DbKey。' + : '点击后会自动启动微信并等待 Hook 安装完成。'} +
- xwechat_files 目录 - setDbPath(e.target.value)} /> + {isMac ? '微信版本目录或旧版 xwechat_files 根目录' : 'xwechat_files 目录'} + setDbPath(e.target.value)} + />
@@ -1279,8 +1348,13 @@ function SettingsPage() {
- 留空使用默认目录,尽可能不选择C盘 - setCachePath(e.target.value)} /> + {isMac ? '留空使用文稿目录下的 CipherTalkData' : '留空使用默认目录,尽可能不选择C盘'} + setCachePath(e.target.value)} + />
@@ -1318,7 +1392,7 @@ function SettingsPage() {
- 2位十六进制,如 0x53 + {isMac ? 'kvcomm 校验成功后返回的 XOR 密钥,格式如 0x53' : '2位十六进制,如 0x53'}
setImageXorKey(e.target.value)} /> + + {isMac ? '优先扫描 kvcomm 和模板文件;只有前者不可用时才回退到微信进程内存扫描。' : '请先在电脑微信中打开几张图片,再执行自动获取。'} +
) @@ -1365,7 +1442,8 @@ function SettingsPage() { try { // 构建用户目录路径(用于 wxid 匹配) - const userDir = `${dbPath}\\${wxid}` + const separator = dbPath.includes('\\') && !dbPath.includes('/') ? '\\' : '/' + const userDir = `${dbPath.replace(/[\\/]+$/, '')}${separator}${wxid}` const removeListener = window.electronAPI.imageKey.onProgress((msg) => { setImageKeyStatus(msg) @@ -2158,6 +2236,11 @@ function SettingsPage() { const handleSecurityMethodSelect = async (method: 'biometric' | 'password') => { + if (method === 'biometric' && isMac) { + showMessage('当前平台不支持 Windows Hello,请改用自定义密码。', false) + return + } + // 1. 如果点击的是当前已激活的方法 -> 关闭 if (isAuthEnabled && authMethod === method) { await disableAuth() @@ -2212,40 +2295,43 @@ function SettingsPage() { const renderSecurityTab = () => (

安全保护

-
配置应用启动时的安全验证方式,保护您的隐私数据。
+
+ {isMac ? 'macOS 当前仅支持自定义应用密码。Windows Hello 在此平台直接禁用。' : '配置应用启动时的安全验证方式,保护您的隐私数据。'} +
- {/* Windows Hello Card */} -
handleSecurityMethodSelect('biometric')} - style={{ cursor: 'pointer' }} - > -
-
-
- -
-
- Windows Hello -
-
-
-
-
-
- Windows Hello - {isAuthEnabled && authMethod === 'biometric' && ( -
- + {!isMac && ( +
handleSecurityMethodSelect('biometric')} + style={{ cursor: 'pointer' }} + > +
+
+
+
- )} +
+ Windows Hello +
+
+
-
- 使用系统的面部识别、指纹或 PIN 码进行验证。体验最流畅,安全性高。 +
+
+ Windows Hello + {isAuthEnabled && authMethod === 'biometric' && ( +
+ +
+ )} +
+
+ 使用系统的面部识别、指纹或 PIN 码进行验证。体验最流畅,安全性高。 +
-
+ )} {/* Custom Password Card */}
- 设置应用专属密码。如果不方便使用生物识别,或者需要在多台设备间同步配置时推荐。 + {isMac + ? '设置应用专属密码。当前 macOS 侧只提供这一种应用锁方式。' + : '设置应用专属密码。如果不方便使用生物识别,或者需要在多台设备间同步配置时推荐。'}
{/* Input area - prevent click propagation to avoid toggling card off while typing */} diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 5b20995..69529bc 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -13,7 +13,7 @@ import './WelcomePage.scss' const steps = [ { id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' }, - { id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' }, + { id: 'db', title: '数据库目录', desc: '定位微信数据目录' }, { id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置' }, { id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' }, { id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' }, @@ -59,6 +59,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const [decryptStatus, setDecryptStatus] = useState('') const [countdown, setCountdown] = useState(0) const [hasCache, setHasCache] = useState(false) + const [platformInfo, setPlatformInfo] = useState<{ platform: string; arch: string }>({ + platform: 'win32', + arch: 'x64' + }) + + const isMac = platformInfo.platform === 'darwin' useEffect(() => { const removeStatus = window.electronAPI.wxKey?.onStatus?.((payload) => { @@ -76,6 +82,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setImageKeyStatus(msg) }) + void window.electronAPI.app.getPlatformInfo().then(setPlatformInfo).catch(() => { + // ignore + }) + // 请求通知权限 if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission() @@ -671,14 +681,24 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {

数据库目录说明

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

    -
  • 微信 → 设置 → 账号与存储 → 存储位置
  • -
  • 按照上面的路径找到 xwechat_files 目录
  • -
  • 路径中不能包含中文字符
  • + {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 && ( +
+ + 如路径包含中文,请在微信中更改存储位置 +
+ )}
)} @@ -687,8 +707,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {

缓存目录说明

用于存储解密后的图片、表情等媒体文件。

    -
  • 自动检测可用磁盘(优先 D、E、F 盘)
  • -
  • 避免使用系统盘(C盘)
  • +
  • {isMac ? 'macOS 默认使用文稿目录下的 CipherTalkData' : '自动检测可用磁盘(优先 D、E、F 盘)'}
  • +
  • {isMac ? '也可以手动指定到其他本地目录' : '避免使用系统盘(C盘)'}
  • 需要足够的存储空间
  • 可以手动修改路径
@@ -700,13 +720,13 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {

解密密钥说明

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

    -
  • 点击"自动获取"会自动启动微信
  • -
  • 等待提示"hook安装成功"后登录
  • -
  • 登录后会自动识别账号
  • +
  • {isMac ? 'macOS 通过 helper + 断点捕获获取 DbKey' : '点击"自动获取"会自动启动微信'}
  • +
  • {isMac ? '此流程要求先关闭 SIP,并允许管理员提权' : '等待提示"hook安装成功"后登录'}
  • +
  • {isMac ? '成功后会自动回填 64 位 DbKey 并尝试识别账号' : '登录后会自动识别账号'}
- 密钥仅保存在本地,不会上传 + {isMac ? '若 SIP 未关闭,自动获取会直接失败并给出提示' : '密钥仅保存在本地,不会上传'}
)} @@ -716,8 +736,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {

图片密钥说明

用于解密微信图片的密钥(可选)。

    -
  • 点击"自动获取"从本地缓存目录扫描
  • -
  • 无需启动微信,秒级获取
  • +
  • 优先通过本地缓存目录和 kvcomm 码推导图片密钥
  • +
  • {isMac ? 'kvcomm 失败时才回退到微信进程内存扫描' : '无需启动微信,秒级获取'}
  • 自动匹配当前 wxid 的密钥
  • 如无法获取,可手动填写
@@ -727,17 +747,26 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {currentStep.id === 'security' && (

安全防护说明

-

为应用添加额外的安全保护(可选)。

-
    -
  • 启用后每次启动需要验证
  • -
  • 使用 Windows Hello 进行认证
  • -
  • 支持面部识别、指纹或 PIN 码
  • -
  • 保护您的聊天记录隐私
  • -
-
- - 推荐在公共电脑上开启此功能 -
+

{isMac ? '当前向导不提供 macOS 系统应用锁,后续可在设置中改用自定义密码。' : '为应用添加额外的安全保护(可选)。'}

+ {isMac ? ( +
+ + Windows Hello 仅在 Windows 上可用,macOS 不做假支持。 +
+ ) : ( + <> +
    +
  • 启用后每次启动需要验证
  • +
  • 使用 Windows Hello 进行认证
  • +
  • 支持面部识别、指纹或 PIN 码
  • +
  • 保护您的聊天记录隐私
  • +
+
+ + 推荐在公共电脑上开启此功能 +
+ + )}
)} @@ -787,15 +816,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setDbPath(e.target.value)} /> -
请选择微信-设置-存储位置对应的目录
-
⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录
+
{isMac ? '请选择微信版本目录或账号根目录' : '请选择微信-设置-存储位置对应的目录'}
+ {!isMac && ( +
⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录
+ )}
)} @@ -805,7 +838,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setCachePath(e.target.value)} /> @@ -817,7 +850,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { 恢复默认
-
用于头像、表情与图片缓存,已自动选择最佳磁盘
+
{isMac ? '用于头像、表情与图片缓存,默认已选文稿目录' : '用于头像、表情与图片缓存,已自动选择最佳磁盘'}
)} @@ -883,7 +916,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {isFetchingDbKey ? '获取中...' : '自动获取密钥'} - {showWechatPathPrompt && ( + {!isMac && showWechatPathPrompt && (

未能自动找到微信安装位置,请手动选择 Weixin.exe

{dbKeyStatus}
} -
获取密钥会自动启动微信并识别候选账号目录
-
点击自动获取后等待提示hook安装成功,然后登录微信即可
+
{isMac ? '获取密钥会调用 mac helper,并尝试识别候选账号目录' : '获取密钥会自动启动微信并识别候选账号目录'}
+
+ {isMac ? 'macOS 要求先关闭 SIP;点击后会弹出管理员授权,再等待微信触发数据库访问即可。' : <>点击自动获取后等待提示hook安装成功,然后登录微信即可} +
)} @@ -933,69 +968,83 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} {imageKeyStatus &&
{imageKeyStatus}
} -
请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作
- {isFetchingImageKey &&
正在扫描内存,请稍候...
} +
{isMac ? '优先从 kvcomm 和模板文件推导,若失败再回退到内存扫描。' : '请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作'}
+ {isFetchingImageKey &&
{isMac ? '正在尝试 kvcomm / 内存扫描,请稍候...' : '正在扫描内存,请稍候...'}
}
)} {currentStep.id === 'security' && (
-
-
- + {isMac ? ( +
+
+ +
+

系统应用锁暂不可用

+

+ 当前版本不会在 macOS 上伪装支持 Windows Hello。 +
+ 你可以先跳过这一步,后续在设置页使用自定义密码。 +

-

Windows Hello 认证

-

- 启用 Windows Hello 以保护您的数据。 -
- 启用后,每次打开应用都需要进行生物识别或 PIN 码验证。 -

+ ) : ( +
+
+ +
+

Windows Hello 认证

+

+ 启用 Windows Hello 以保护您的数据。 +
+ 启用后,每次打开应用都需要进行生物识别或 PIN 码验证。 +

-
- {!isAuthEnabled ? ( - - ) : ( -
-
- - 已启用保护 -
+
+ {!isAuthEnabled ? ( + ) : ( +
+
+ + 已启用保护 +
+ +
+ )} +
+ + {authStatus && ( +
+ {authStatus}
)}
- - {authStatus && ( -
- {authStatus} -
- )} -
+ )}
)} diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index 0681a38..6294619 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -25,6 +25,15 @@ interface AuthState { import { hashPassword } from '@/utils/crypto' +async function isWindowsPlatform(): Promise { + try { + const info = await window.electronAPI.app.getPlatformInfo() + return info.platform === 'win32' + } catch { + return false + } +} + // WebAuthn 错误消息映射 function getFriendlyErrorMessage(error: any): string { const msg = error.message || '' @@ -90,6 +99,10 @@ export const useAuthStore = create((set, get) => ({ enableAuth: async () => { try { + if (!(await isWindowsPlatform())) { + return { success: false, error: '当前平台不支持 Windows Hello 应用锁,请改用自定义密码。' } + } + // 优先尝试使用原生 Windows Hello DLL (更快) if (window.electronAPI?.windowsHello) { const available = await window.electronAPI.windowsHello.isAvailable() @@ -221,6 +234,10 @@ export const useAuthStore = create((set, get) => ({ if (!credentialId) return { success: false, error: '未找到凭证' } try { + if (!(await isWindowsPlatform()) && credentialId === 'native-windows-hello') { + return { success: false, error: '当前平台不支持 Windows Hello 解锁' } + } + // 优先使用原生 Windows Hello DLL (更快) if (credentialId === 'native-windows-hello' && window.electronAPI?.windowsHello) { const result = await window.electronAPI.windowsHello.verify('请验证您的身份以解锁 CipherTalk') diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4f9c7ee..d9fc07d 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -75,6 +75,7 @@ export interface ElectronAPI { app: { getDownloadsPath: () => Promise getVersion: () => Promise + getPlatformInfo: () => Promise<{ platform: string; arch: string }> checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }> downloadAndInstall: () => Promise getStartupDbConnected?: () => Promise