mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
修复内存扫描问题
This commit is contained in:
@@ -1539,6 +1539,12 @@ function registerIpcHandlers() {
|
|||||||
}, wxid)
|
}, wxid)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => {
|
||||||
|
return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => {
|
||||||
|
event.sender.send('key:imageKeyStatus', { message })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
ipcMain.handle('http:start', async (_, port?: number) => {
|
ipcMain.handle('http:start', async (_, port?: number) => {
|
||||||
return httpService.start(port || 5031)
|
return httpService.start(port || 5031)
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
key: {
|
key: {
|
||||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||||
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
|
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
|
||||||
|
scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
|
||||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
||||||
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
||||||
|
|||||||
@@ -731,4 +731,256 @@ export class KeyService {
|
|||||||
aesKey
|
aesKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||||
|
// 只扫 RW 可写区域(更快),同时支持 ASCII 和 UTF-16LE 两种密钥格式
|
||||||
|
// 验证支持 JPEG/PNG/WEBP/WXGF/GIF 多种格式
|
||||||
|
|
||||||
|
async autoGetImageKeyByMemoryScan(
|
||||||
|
userDir: string,
|
||||||
|
onProgress?: (message: string) => void
|
||||||
|
): Promise<ImageKeyResult> {
|
||||||
|
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 查找模板文件获取密文和 XOR 密钥
|
||||||
|
onProgress?.('正在查找模板文件...')
|
||||||
|
const { ciphertext, xorKey } = await this._findTemplateData(userDir)
|
||||||
|
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||||
|
|
||||||
|
onProgress?.(`XOR 密钥: 0x${(xorKey ?? 0).toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
|
// 2. 找微信 PID
|
||||||
|
const pid = await this.findWeChatPid()
|
||||||
|
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
||||||
|
|
||||||
|
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
||||||
|
|
||||||
|
// 3. 持续轮询内存扫描,最多 60 秒
|
||||||
|
const deadline = Date.now() + 60_000
|
||||||
|
let scanCount = 0
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
scanCount++
|
||||||
|
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
||||||
|
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||||
|
if (aesKey) {
|
||||||
|
onProgress?.('密钥获取成功')
|
||||||
|
return { success: true, xorKey: xorKey ?? 0, aesKey }
|
||||||
|
}
|
||||||
|
// 等 5 秒再试
|
||||||
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '60 秒内未找到 AES 密钥。\n请确保已在微信中打开 2-3 张图片大图后再试。'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: `内存扫描失败: ${e}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _findTemplateData(userDir: string): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||||
|
const { readdirSync, readFileSync, statSync } = await import('fs')
|
||||||
|
const { join } = await import('path')
|
||||||
|
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||||
|
|
||||||
|
// 递归收集 *_t.dat 文件
|
||||||
|
const collect = (dir: string, results: string[], limit = 32) => {
|
||||||
|
if (results.length >= limit) return
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (results.length >= limit) break
|
||||||
|
const full = join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) collect(full, results, limit)
|
||||||
|
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||||
|
}
|
||||||
|
} catch { /* 忽略无权限目录 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: string[] = []
|
||||||
|
collect(userDir, files)
|
||||||
|
|
||||||
|
// 按修改时间降序
|
||||||
|
files.sort((a, b) => {
|
||||||
|
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
let ciphertext: Buffer | null = null
|
||||||
|
const tailCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _scanMemoryForAesKey(
|
||||||
|
pid: number,
|
||||||
|
ciphertext: Buffer,
|
||||||
|
onProgress?: (msg: string) => void
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!this.ensureKernel32()) return null
|
||||||
|
|
||||||
|
// 直接用已加载的 kernel32 实例,用 uintptr 传地址
|
||||||
|
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t'])
|
||||||
|
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')])
|
||||||
|
|
||||||
|
// RW 保护标志(只扫可写区域,速度更快)
|
||||||
|
const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||||
|
const MEM_COMMIT = 0x1000
|
||||||
|
const PAGE_NOACCESS = 0x01
|
||||||
|
const PAGE_GUARD = 0x100
|
||||||
|
const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64
|
||||||
|
|
||||||
|
const hProcess = this.OpenProcess(0x1F0FFF, false, pid)
|
||||||
|
if (!hProcess) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 枚举 RW 内存区域
|
||||||
|
const regions: Array<[number, number]> = []
|
||||||
|
let addr = 0
|
||||||
|
const mbi = Buffer.alloc(MBI_SIZE)
|
||||||
|
|
||||||
|
while (addr < 0x7FFFFFFFFFFF) {
|
||||||
|
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
|
||||||
|
if (ret === 0) break
|
||||||
|
// MEMORY_BASIC_INFORMATION x64 布局:
|
||||||
|
// 0: BaseAddress (8)
|
||||||
|
// 8: AllocationBase (8)
|
||||||
|
// 16: AllocationProtect (4) + 4 padding
|
||||||
|
// 24: RegionSize (8)
|
||||||
|
// 32: State (4)
|
||||||
|
// 36: Protect (4)
|
||||||
|
// 40: Type (4) + 4 padding = 48 total
|
||||||
|
const base = Number(mbi.readBigUInt64LE(0))
|
||||||
|
const size = Number(mbi.readBigUInt64LE(24))
|
||||||
|
const state = mbi.readUInt32LE(32)
|
||||||
|
const protect = mbi.readUInt32LE(36)
|
||||||
|
|
||||||
|
if (state === MEM_COMMIT &&
|
||||||
|
protect !== PAGE_NOACCESS &&
|
||||||
|
(protect & PAGE_GUARD) === 0 &&
|
||||||
|
(protect & RW_FLAGS) !== 0 &&
|
||||||
|
size <= 50 * 1024 * 1024) {
|
||||||
|
regions.push([base, size])
|
||||||
|
}
|
||||||
|
const next = base + size
|
||||||
|
if (next <= addr) break
|
||||||
|
addr = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMB = regions.reduce((s, [, sz]) => s + sz, 0) / 1024 / 1024
|
||||||
|
onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`)
|
||||||
|
|
||||||
|
const CHUNK = 4 * 1024 * 1024
|
||||||
|
const OVERLAP = 65
|
||||||
|
|
||||||
|
for (let i = 0; i < regions.length; i++) {
|
||||||
|
const [base, size] = regions[i]
|
||||||
|
if (i % 20 === 0) {
|
||||||
|
onProgress?.(`扫描进度 ${i}/${regions.length}...`)
|
||||||
|
await new Promise(r => setTimeout(r, 1)) // 让出事件循环
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
let trailing: Buffer | null = null
|
||||||
|
|
||||||
|
while (offset < size) {
|
||||||
|
const chunkSize = Math.min(CHUNK, size - offset)
|
||||||
|
const buf = Buffer.alloc(chunkSize)
|
||||||
|
const bytesReadOut = [0]
|
||||||
|
const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut)
|
||||||
|
if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue }
|
||||||
|
|
||||||
|
const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0])
|
||||||
|
|
||||||
|
// 搜索 ASCII 32字节密钥
|
||||||
|
const key = this._searchAsciiKey(data, ciphertext)
|
||||||
|
if (key) { this.CloseHandle(hProcess); return key }
|
||||||
|
|
||||||
|
// 搜索 UTF-16LE 32字节密钥
|
||||||
|
const key16 = this._searchUtf16Key(data, ciphertext)
|
||||||
|
if (key16) { this.CloseHandle(hProcess); return key16 }
|
||||||
|
|
||||||
|
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
|
||||||
|
offset += chunkSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
this.CloseHandle(hProcess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _isAlphaNum(b: number): boolean {
|
||||||
|
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 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()])
|
||||||
|
// 支持 JPEG / PNG / WEBP / WXGF / GIF
|
||||||
|
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||||
|
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||||
|
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||||
|
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
|
return false
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -768,42 +768,25 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const handleAutoGetImageKey = async () => {
|
const handleAutoGetImageKey = async () => {
|
||||||
if (isFetchingImageKey) return;
|
if (isFetchingImageKey) return;
|
||||||
if (!dbPath) {
|
if (!dbPath) { showMessage('请先选择数据库目录', false); return; }
|
||||||
showMessage('请先选择数据库目录', false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsFetchingImageKey(true);
|
setIsFetchingImageKey(true);
|
||||||
setImageKeyPercent(0)
|
setImageKeyPercent(0)
|
||||||
setImageKeyStatus('正在初始化...');
|
setImageKeyStatus('正在初始化...');
|
||||||
setImageKeyProgress(0); // 重置进度
|
setImageKeyProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
|
||||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
||||||
if (result.success && result.aesKey) {
|
if (result.success && result.aesKey) {
|
||||||
if (typeof result.xorKey === 'number') {
|
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
|
||||||
}
|
|
||||||
setImageAesKey(result.aesKey)
|
setImageAesKey(result.aesKey)
|
||||||
setImageKeyStatus('已获取图片密钥')
|
setImageKeyStatus('已获取图片密钥')
|
||||||
showMessage('已自动获取图片密钥', true)
|
showMessage('已自动获取图片密钥', true)
|
||||||
|
|
||||||
// Auto-save after fetching keys
|
|
||||||
// We need to use the values directly because state updates are async
|
|
||||||
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
||||||
const newAesKey = result.aesKey
|
const newAesKey = result.aesKey
|
||||||
|
|
||||||
await configService.setImageXorKey(newXorKey)
|
await configService.setImageXorKey(newXorKey)
|
||||||
await configService.setImageAesKey(newAesKey)
|
await configService.setImageAesKey(newAesKey)
|
||||||
|
if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey })
|
||||||
if (wxid) {
|
|
||||||
await configService.setWxidConfig(wxid, {
|
|
||||||
decryptKey: decryptKey, // use current state as it hasn't changed here
|
|
||||||
imageXorKey: newXorKey,
|
|
||||||
imageAesKey: newAesKey
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
showMessage(result.error || '自动获取图片密钥失败', false)
|
showMessage(result.error || '自动获取图片密钥失败', false)
|
||||||
}
|
}
|
||||||
@@ -814,6 +797,36 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScanImageKeyFromMemory = async () => {
|
||||||
|
if (isFetchingImageKey) return;
|
||||||
|
if (!dbPath) { showMessage('请先选择数据库目录', false); return; }
|
||||||
|
setIsFetchingImageKey(true);
|
||||||
|
setImageKeyPercent(0)
|
||||||
|
setImageKeyStatus('正在扫描内存...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
|
||||||
|
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
|
||||||
|
if (result.success && result.aesKey) {
|
||||||
|
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
|
setImageAesKey(result.aesKey)
|
||||||
|
setImageKeyStatus('内存扫描成功,已获取图片密钥')
|
||||||
|
showMessage('内存扫描成功,已获取图片密钥', true)
|
||||||
|
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
||||||
|
const newAesKey = result.aesKey
|
||||||
|
await configService.setImageXorKey(newXorKey)
|
||||||
|
await configService.setImageAesKey(newAesKey)
|
||||||
|
if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey })
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || '内存扫描获取图片密钥失败', false)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`内存扫描失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsFetchingImageKey(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
@@ -1373,24 +1386,27 @@ function SettingsPage() {
|
|||||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
<div className="form-hint" style={{ color: '#f59e0b', margin: '6px 0' }}>
|
||||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用「内存扫描」方案。
|
||||||
</button>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
||||||
|
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高">
|
||||||
|
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{isFetchingImageKey ? (
|
{isFetchingImageKey ? (
|
||||||
<div className="brute-force-progress">
|
<div className="brute-force-progress">
|
||||||
<div className="status-header">
|
<div className="status-header">
|
||||||
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||||
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{imageKeyPercent !== null && (
|
|
||||||
<div className="progress-bar-container">
|
|
||||||
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
||||||
)}
|
)}
|
||||||
|
<span className="form-hint">内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -309,22 +309,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
const handleAutoGetImageKey = async () => {
|
const handleAutoGetImageKey = async () => {
|
||||||
if (isFetchingImageKey) return
|
if (isFetchingImageKey) return
|
||||||
if (!dbPath) {
|
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||||
setError('请先选择数据库目录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsFetchingImageKey(true)
|
setIsFetchingImageKey(true)
|
||||||
setError('')
|
setError('')
|
||||||
setImageKeyPercent(0)
|
setImageKeyPercent(0)
|
||||||
setImageKeyStatus('正在准备获取图片密钥...')
|
setImageKeyStatus('正在准备获取图片密钥...')
|
||||||
try {
|
try {
|
||||||
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
|
|
||||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
||||||
if (result.success && result.aesKey) {
|
if (result.success && result.aesKey) {
|
||||||
if (typeof result.xorKey === 'number') {
|
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
|
||||||
}
|
|
||||||
setImageAesKey(result.aesKey)
|
setImageAesKey(result.aesKey)
|
||||||
setImageKeyStatus('已获取图片密钥')
|
setImageKeyStatus('已获取图片密钥')
|
||||||
} else {
|
} else {
|
||||||
@@ -337,6 +331,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScanImageKeyFromMemory = async () => {
|
||||||
|
if (isFetchingImageKey) return
|
||||||
|
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||||
|
setIsFetchingImageKey(true)
|
||||||
|
setError('')
|
||||||
|
setImageKeyPercent(0)
|
||||||
|
setImageKeyStatus('正在扫描内存...')
|
||||||
|
try {
|
||||||
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||||
|
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
|
||||||
|
if (result.success && result.aesKey) {
|
||||||
|
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
|
setImageAesKey(result.aesKey)
|
||||||
|
setImageKeyStatus('内存扫描成功,已获取图片密钥')
|
||||||
|
} else {
|
||||||
|
setError(result.error || '内存扫描获取图片密钥失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`内存扫描失败: ${e}`)
|
||||||
|
} finally {
|
||||||
|
setIsFetchingImageKey(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canGoNext = () => {
|
const canGoNext = () => {
|
||||||
if (currentStep.id === 'intro') return true
|
if (currentStep.id === 'intro') return true
|
||||||
if (currentStep.id === 'db') return Boolean(dbPath)
|
if (currentStep.id === 'db') return Boolean(dbPath)
|
||||||
@@ -747,50 +765,40 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
{currentStep.id === 'image' && (
|
{currentStep.id === 'image' && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
<div className="field-hint" style={{ color: '#f59e0b', marginBottom: '12px' }}>
|
||||||
|
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用下方「内存扫描」方案。
|
||||||
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">图片 XOR 密钥</label>
|
<label className="field-label">图片 XOR 密钥</label>
|
||||||
<input
|
<input type="text" className="field-input" placeholder="0x..." value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="0x..."
|
|
||||||
value={imageXorKey}
|
|
||||||
onChange={(e) => setImageXorKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">图片 AES 密钥</label>
|
<label className="field-label">图片 AES 密钥</label>
|
||||||
<input
|
<input type="text" className="field-input" placeholder="16位密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
|
||||||
type="text"
|
|
||||||
className="field-input"
|
|
||||||
placeholder="16位密钥"
|
|
||||||
value={imageAesKey}
|
|
||||||
onChange={(e) => setImageAesKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
<button className="btn btn-secondary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
||||||
</button>
|
{isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高,需要微信正在运行">
|
||||||
|
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isFetchingImageKey ? (
|
{isFetchingImageKey ? (
|
||||||
<div className="brute-force-progress">
|
<div className="brute-force-progress">
|
||||||
<div className="status-header">
|
<div className="status-header">
|
||||||
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||||
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{imageKeyPercent !== null && (
|
|
||||||
<div className="progress-bar-container">
|
|
||||||
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="field-hint">请在微信中打开几张图片后再点击获取</div>
|
<div className="field-hint" style={{ marginTop: '8px' }}>内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -67,6 +67,7 @@ export interface ElectronAPI {
|
|||||||
key: {
|
key: {
|
||||||
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
||||||
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||||
|
scanImageKeyFromMemory: (userDir: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
|
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
|
||||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user