diff --git a/.gitignore b/.gitignore index 0623e78..123a279 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ CLAUDE.md resources/wx_send 概述.md pnpm-lock.yaml +/pnpm-workspace.yaml diff --git a/electron/main.ts b/electron/main.ts index 6ba867a..5066c98 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -17,6 +17,7 @@ import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { KeyService } from './services/keyService' import { KeyServiceMac } from './services/keyServiceMac' +import { KeyServiceLinux } from './services/keyServiceLinux'; import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' import { snsService, isVideoUrl } from './services/snsService' @@ -89,9 +90,15 @@ let onboardingWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null const sessionChatWindows = new Map() const sessionChatWindowSources = new Map() -const keyService = process.platform === 'darwin' - ? new KeyServiceMac() as any - : new KeyService() + +let keyService: KeyService | KeyServiceMac | KeyServiceLinux; +if (process.platform === 'darwin') { + keyService = new KeyServiceMac(); +} else if (process.platform === 'linux') { + keyService = new KeyServiceLinux(); +} else { + keyService = new KeyService(); +} let mainWindowReady = false let shouldShowMain = true diff --git a/electron/services/config.ts b/electron/services/config.ts index d783c49..141d216 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -16,7 +16,7 @@ interface ConfigSchema { imageXorKey: number imageAesKey: string wxidConfigs: Record - + exportPath?: string; // 缓存相关 cachePath: string lastOpenedDb: string diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts new file mode 100644 index 0000000..14127ab --- /dev/null +++ b/electron/services/keyServiceLinux.ts @@ -0,0 +1,282 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { execFile, exec } from 'child_process' +import { promisify } from 'util' +import sudo from 'sudo-prompt' + +const execFileAsync = promisify(execFile) +const execAsync = promisify(exec) + +type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } + +export class KeyServiceLinux { + + private getHelperPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH) + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux')) + candidates.push(join(process.resourcesPath, 'xkey_helper_linux')) + } else { + candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux')) + candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux')) + } + for (const p of candidates) { + if (existsSync(p)) return p + } + throw new Error('找不到 xkey_helper_linux,请检查路径') + } + + public async autoGetDbKey( + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void + ): Promise { + try { + onStatus?.('正在尝试结束当前微信进程...', 0) + await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {}) + // 稍微等待进程完全退出 + await new Promise(r => setTimeout(r, 1000)) + + onStatus?.('正在尝试拉起微信...', 0) + const startCmds = [ + 'nohup wechat >/dev/null 2>&1 &', + 'nohup wechat-bin >/dev/null 2>&1 &', + 'nohup xwechat >/dev/null 2>&1 &' + ] + for (const cmd of startCmds) execAsync(cmd).catch(() => {}) + + onStatus?.('等待微信进程出现...', 0) + let pid = 0 + for (let i = 0; i < 15; i++) { // 最多等 15 秒 + await new Promise(r => setTimeout(r, 1000)) + const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' })) + const pids = stdout.trim().split(/\s+/).filter(p => p) + if (pids.length > 0) { + pid = parseInt(pids[0], 10) + break + } + } + + if (!pid) { + const err = '未能自动启动微信,请手动启动并登录。' + onStatus?.(err, 2) + return { success: false, error: err } + } + + onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0) + + await new Promise(r => setTimeout(r, 2000)) + + return await this.getDbKey(pid, onStatus) + } catch (err: any) { + const errMsg = '自动获取微信 PID 失败: ' + err.message + onStatus?.(errMsg, 2) + return { success: false, error: errMsg } + } + } + + public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise { + try { + const helperPath = this.getHelperPath() + + onStatus?.('正在扫描数据库基址...', 0) + const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()]) + const scanRes = JSON.parse(scanOut.trim()) + + if (!scanRes.success) { + const err = scanRes.result || '扫描失败,请确保微信已完全登录' + onStatus?.(err, 2) + return { success: false, error: err } + } + + const targetAddr = scanRes.target_addr + onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0) + + return await new Promise((resolve) => { + const options = { name: 'WeFlow' } + const command = `"${helperPath}" db_hook ${pid} ${targetAddr}` + + sudo.exec(command, options, (error, stdout) => { + execAsync(`kill -CONT ${pid}`).catch(() => {}) + if (error) { + onStatus?.('授权失败或被取消', 2) + resolve({ success: false, error: `授权失败或被取消: ${error.message}` }) + return + } + try { + const hookRes = JSON.parse((stdout as string).trim()) + if (hookRes.success) { + onStatus?.('密钥获取成功', 1) + resolve({ success: true, key: hookRes.key }) + } else { + onStatus?.(hookRes.result, 2) + resolve({ success: false, error: hookRes.result }) + } + } catch (e) { + onStatus?.('解析 Hook 结果失败', 2) + resolve({ success: false, error: '解析 Hook 结果失败' }) + } + }) + }) + } catch (err: any) { + onStatus?.(err.message, 2) + return { success: false, error: err.message } + } + } + + public async autoGetImageKey( + accountPath?: string, + onProgress?: (msg: string) => void, + wxid?: string + ): Promise { + try { + onProgress?.('正在初始化缓存扫描...'); + const helperPath = this.getHelperPath() + const { stdout } = await execFileAsync(helperPath, ['image_local']) + const res = JSON.parse(stdout.trim()) + if (!res.success) return { success: false, error: res.result } + + const accounts = res.data.accounts || [] + let account = accounts.find((a: any) => a.wxid === wxid) + if (!account && accounts.length > 0) account = accounts[0] + + if (account && account.keys && account.keys.length > 0) { + onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`); + const keyObj = account.keys[0] + return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey } + } + return { success: false, error: '未在缓存中找到匹配的图片密钥' } + } catch (err: any) { + return { success: false, error: err.message } + } + } + + public async autoGetImageKeyByMemoryScan( + accountPath: string, + onProgress?: (msg: string) => void + ): Promise { + try { + onProgress?.('正在查找模板文件...') + let result = await this._findTemplateData(accountPath, 32) + let { ciphertext, xorKey } = result + + if (ciphertext && xorKey === null) { + onProgress?.('未找到有效密钥,尝试扫描更多文件...') + result = await this._findTemplateData(accountPath, 100) + xorKey = result.xorKey + } + + if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' } + + onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) + + // 2. 找微信 PID + const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' })) + const pids = stdout.trim().split(/\s+/).filter(p => p) + if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' } + const pid = parseInt(pids[0], 10) + + onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`); + + // 3. 将 Buffer 转换为 hex 传递给 helper + const ciphertextHex = ciphertext.toString('hex') + const helperPath = this.getHelperPath() + + try { + console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`); + + const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex]) + + console.log(`[Debug] Helper stdout: ${memOut}`); + if (stderr) { + console.warn(`[Debug] Helper stderr: ${stderr}`); + } + + if (!memOut || memOut.trim() === '') { + return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' } + } + + const res = JSON.parse(memOut.trim()) + + if (res.success) { + onProgress?.('内存扫描成功'); + return { success: true, xorKey, aesKey: res.key } + } + return { success: false, error: res.result || '未知错误' } + + } catch (err: any) { + console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err); + return { + success: false, + error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}` + } + } + } catch (err: any) { + return { success: false, error: `内存扫描失败: ${err.message}` } + } + } + + private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + + // 递归收集 *_t.dat 文件 + const collect = (dir: string, results: string[], maxFiles: number) => { + if (results.length >= maxFiles) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (results.length >= maxFiles) break + const full = join(dir, entry.name) + if (entry.isDirectory()) collect(full, results, maxFiles) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) + } + } catch { /* 忽略无权限目录 */ } + } + + const files: string[] = [] + collect(userDir, files, limit) + + // 按修改时间降序 + files.sort((a, b) => { + try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } + }) + + let ciphertext: Buffer | null = null + const tailCounts: Record = {} + + for (const f of files.slice(0, 32)) { + try { + const data = readFileSync(f) + if (data.length < 8) continue + + // 统计末尾两字节用于 XOR 密钥 + if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } + + // 提取密文(取第一个有效的) + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { + ciphertext = data.subarray(0xF, 0x1F) + } + } catch { /* 忽略 */ } + } + + // 计算 XOR 密钥 + let xorKey: number | null = null + let maxCount = 0 + for (const [key, count] of Object.entries(tailCounts)) { + if (count > maxCount) { + maxCount = count + const [x, y] = key.split('_').map(Number) + const k = x ^ 0xFF + if (k === (y ^ 0xD9)) xorKey = k + } + } + + return { ciphertext, xorKey } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 569874d..21b618e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", + "sudo-prompt": "^9.2.1", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, diff --git a/resources/xkey_helper_linux b/resources/xkey_helper_linux new file mode 100755 index 0000000..54f7cb3 Binary files /dev/null and b/resources/xkey_helper_linux differ diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index e52e3cb..127f092 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -30,6 +30,16 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'about', label: '关于', icon: Info } ] +const isMac = navigator.userAgent.toLowerCase().includes('mac') +const isLinux = navigator.userAgent.toLowerCase().includes('linux') + +const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' +const dbPathPlaceholder = isMac + ? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9' + : isLinux + ? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files' + : '例如: C:\\Users\\xxx\\Documents\\xwechat_files' + interface WxidOption { wxid: string @@ -1371,7 +1381,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { xwechat_files 目录 { const value = e.target.value diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 185b23b..ff1fd0d 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -11,9 +11,19 @@ import { import ConfirmDialog from '../components/ConfirmDialog' import './WelcomePage.scss' +const isMac = navigator.userAgent.toLowerCase().includes('mac') +const isLinux = navigator.userAgent.toLowerCase().includes('linux') + +const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' +const dbPathPlaceholder = isMac + ? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9' + : isLinux + ? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files' + : '例如: C:\\Users\\xxx\\Documents\\xwechat_files' + const steps = [ { id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' }, - { id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' }, + { id: 'db', title: '数据库目录', desc: `定位 ${dbDirName}` }, { id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' }, { id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' }, { id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' }, @@ -637,7 +647,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setDbPath(e.target.value)} /> @@ -888,13 +898,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setShowDbKeyConfirm(false)} + open={showDbKeyConfirm} + title="开始获取数据库密钥" + message={`当开始获取后 WeFlow 将会执行准备操作。 +${isLinux ? ` +【⚠️ Linux 用户特别注意】 +如果您在微信里勾选了“自动登录”,请务必先关闭自动登录,然后再点击下方确认! +(因为授权弹窗输入密码需要时间,若自动登录太快会导致获取失败) +` : ''} +当 WeFlow 内的提示条变为绿色显示允许登录或看到来自 WeFlow 的登录通知时,请在手机上确认登录微信。`} + onConfirm={handleDbKeyConfirm} + onCancel={() => setShowDbKeyConfirm(false)} />