diff --git a/.env.example b/.env.example index 145a73c..6492c05 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ VITE_AI_MODEL=deepseek-chat VITE_FILTER_MSG_TYPES= # Image Decryption Keys (Optional, for WeChat 4.0+ image decryption) -# These are used to decrypt image .dat files in WeChat 4.0+ +# These are dev fallbacks. End users can fill or auto-fetch them in Settings. # XOR Key: hex format like 0x40, 0x53 etc. # AES Key: 16-character string, derived from wxid and code VITE_IMAGE_XOR_KEY= diff --git a/README.md b/README.md index 43a270d..4b34366 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。 ### 环境变量配置 (.env) -可选配置项,可在 `.env` 文件中设置: +可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。 | 变量名 | 说明 | 示例 | | ----------------------- | --------------------------- | --------------------------- | @@ -96,7 +96,8 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。 1. 在应用登录界面点击“自动获取密钥” 2. 从 WeFlow/Chatlog 设置中导出 -3. 如自动获取失败,可手动粘贴已获取的密钥 +3. 在软件“设置 -> 图片解密密钥”中自动获取或手动填写 +4. 如自动获取失败,可手动粘贴已获取的密钥 ## 🤖 AI 集成(本地 HTTP API) diff --git a/package.json b/package.json index 6272e81..867c70c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", + "cp:env": "node scripts/ensure-env.cjs", + "prepare:env": "node scripts/ensure-env.cjs", + "predev": "node scripts/ensure-env.cjs", "start": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", diff --git a/scripts/ensure-env.cjs b/scripts/ensure-env.cjs new file mode 100644 index 0000000..fdd8b97 --- /dev/null +++ b/scripts/ensure-env.cjs @@ -0,0 +1,13 @@ +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '..') +const source = path.join(root, '.env.example') +const target = path.join(root, '.env') + +if (!fs.existsSync(source) || fs.existsSync(target)) { + process.exit(0) +} + +fs.copyFileSync(source, target) +console.log('[ensure-env] created .env from .env.example') diff --git a/src/main/index.ts b/src/main/index.ts index 30663af..2872520 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,6 +47,24 @@ const BUILD_MARK = 'wechat4-local-http-api-2026-07-03' const TRAY_MODE = process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1' +function normalizeImageXorKey(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + const parsed = raw.toLowerCase().startsWith('0x') + ? Number.parseInt(raw.slice(2), 16) + : Number.parseInt(raw, 10) + if (!Number.isFinite(parsed)) return raw + return `0x${Math.max(0, parsed & 0xff).toString(16).toUpperCase().padStart(2, '0')}` +} + +function getConfiguredImageKeys(): { xorKey: string; aesKey: string } { + const settings = loadSettings() + return { + xorKey: settings.imageXorKey || import.meta.env.VITE_IMAGE_XOR_KEY || '0x40', + aesKey: settings.imageAesKey || import.meta.env.VITE_IMAGE_AES_KEY || '' + } +} + function createWindow(): void { // 创建浏览器窗口 @@ -180,6 +198,36 @@ app.whenReady().then(async () => { } }) + ipcMain.handle('key:autoGetImageKey', async (event) => { + const settings = loadSettings() + const self = chat.getSelfAccountInfo() + const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot + const wxid = self?.wxid + const onStatus = (message: string): void => { + if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message }) + } + const result = + process.platform === 'win32' + ? await keyServiceWin.autoGetImageKeyByMemoryScan(accountRoot, onStatus) + : await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid) + + if (!result.success || !result.aesKey) return result + + const imageXorKey = normalizeImageXorKey(result.xorKey) + const nextSettings = saveSettings({ + ...settings, + imageXorKey, + imageAesKey: result.aesKey + }) + imageDecryptService = null + return { + ...result, + imageXorKey, + imageAesKey: result.aesKey, + settings: nextSettings + } + }) + ipcMain.handle('db:getContacts', (_, filter?: string) => chat.listContacts(filter)) ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => @@ -275,9 +323,7 @@ app.whenReady().then(async () => { async (_, imageMd5?: string, imageDatNameOrThumb?: string | boolean, _sessionId?: string) => { void _sessionId if (!imageDecryptService) { - // 从环境变量获取密钥 - const xorKey = import.meta.env.VITE_IMAGE_XOR_KEY || '0x40' - const aesKey = import.meta.env.VITE_IMAGE_AES_KEY || '' + const { xorKey, aesKey } = getConfiguredImageKeys() if (!aesKey) { return { success: false, error: '未配置图片解密密钥' } } @@ -314,7 +360,14 @@ app.whenReady().then(async () => { })) ipcMain.handle('settings:set', (_, patch: Partial) => { - const merged = saveSettings({ ...loadSettings(), ...patch }) + const before = loadSettings() + const merged = saveSettings({ ...before, ...patch }) + if ( + before.imageXorKey !== merged.imageXorKey || + before.imageAesKey !== merged.imageAesKey + ) { + imageDecryptService = null + } return { settings: merged, settingsPath: getSettingsPath() } }) diff --git a/src/main/key-service-mac.ts b/src/main/key-service-mac.ts index ab6ba3c..11a6d56 100644 --- a/src/main/key-service-mac.ts +++ b/src/main/key-service-mac.ts @@ -4,6 +4,7 @@ import fs from 'fs-extra' import path from 'path' import { promisify } from 'util' import { isValidDatabaseKey } from './database-key-store' +import crypto from 'crypto' const execFileAsync = promisify(execFile) @@ -14,6 +15,14 @@ export interface DatabaseKeyResult { code?: string } +export interface ImageKeyResult { + success: boolean + xorKey?: number + aesKey?: string + verified?: boolean + error?: string +} + export class KeyServiceMac { private getHelperPath(): string { // 多 candidate fallback:覆盖 extraResources、asarUnpack、dev 三种场景 @@ -175,4 +184,259 @@ export class KeyServiceMac { return { success: false, error: error instanceof Error ? error.message : String(error) } } } + + async autoGetImageKey( + accountPath?: string, + onStatus?: (message: string) => void, + wxid?: string + ): Promise { + try { + onStatus?.('正在从缓存目录扫描图片密钥...') + 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) + + for (const candidateAccountPath of accountPathCandidates) { + if (!fs.existsSync(candidateAccountPath)) continue + const template = this.findTemplateData(candidateAccountPath, 32) + if (!template.ciphertext) continue + + const orderedWxids: string[] = [] + this.pushAccountIdCandidates(orderedWxids, path.basename(candidateAccountPath)) + for (const candidate of wxidCandidates) this.pushAccountIdCandidates(orderedWxids, candidate) + + onStatus?.(`正在校验候选 wxid(${orderedWxids.length} 个)...`) + 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})`) + return { success: true, xorKey, aesKey, verified: true } + } + } + } + + const fallbackWxid = wxidCandidates[0] + const fallbackCode = codes[0] + const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) + onStatus?.(`图片密钥已计算 (wxid: ${fallbackWxid})`) + return { success: true, xorKey, aesKey, verified: false } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + } + } + } + + private collectKvcommCodes(accountPath?: string): number[] { + const codeSet = new Set() + const pattern = /^key_(\d+)_.+\.statistic$/i + for (const kvcommDir of this.getKvcommCandidates(accountPath)) { + if (!fs.existsSync(kvcommDir)) continue + try { + for (const file of fs.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 { + // Try the next candidate. + } + } + return Array.from(codeSet) + } + + private getKvcommCandidates(accountPath?: string): string[] { + const home = app.getPath('home') + const candidates = new Set([ + path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm'), + path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/xwechat/net/kvcomm'), + path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/net/kvcomm'), + path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat/net/kvcomm') + ]) + + const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') + const marker = '/xwechat_files' + const markerIndex = normalized.indexOf(marker) + if (markerIndex >= 0) { + candidates.add(`${normalized.slice(0, markerIndex)}/app_data/net/kvcomm`) + } + + const newPathMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/) + if (newPathMatch) { + candidates.add(`${newPathMatch[1]}/net/kvcomm`) + candidates.add(`${newPathMatch[1]}/xwechat/net/kvcomm`) + } + + return Array.from(candidates) + } + + private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] { + const candidates: string[] = [] + this.pushAccountIdCandidates(candidates, wxidParam) + + const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') + if (normalized) { + this.pushAccountIdCandidates(candidates, path.basename(normalized)) + const root = this.resolveXwechatRootFromPath(normalized) + if (root && fs.existsSync(root)) { + try { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = path.join(root, entry.name) + if (this.isAccountDirPath(entryPath)) this.pushAccountIdCandidates(candidates, entry.name) + } + } catch { + // Ignore unreadable directories. + } + } + } + + if (candidates.length === 0) candidates.push('unknown') + return candidates + } + + private collectAccountPathCandidates(accountPath?: string): string[] { + const candidates: string[] = [] + const push = (value?: string): void => { + const normalized = String(value || '').trim() + if (normalized && !candidates.includes(normalized)) candidates.push(normalized) + } + + push(accountPath) + const root = this.resolveXwechatRootFromPath(accountPath) + if (root && fs.existsSync(root)) { + try { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = path.join(root, entry.name) + if (this.isAccountDirPath(entryPath) && this.isReasonableAccountId(entry.name)) push(entryPath) + } + } catch { + // Ignore unreadable directories. + } + } + return candidates + } + + private resolveXwechatRootFromPath(accountPath?: string): string | null { + const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') + if (!normalized) return null + const marker = '/xwechat_files' + const markerIndex = normalized.indexOf(marker) + if (markerIndex >= 0) return normalized.slice(0, markerIndex + marker.length) + const newPathMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/) + return newPathMatch ? newPathMatch[1] : null + } + + private isAccountDirPath(entryPath: string): boolean { + return ( + fs.existsSync(path.join(entryPath, 'db_storage')) || + fs.existsSync(path.join(entryPath, 'msg')) || + fs.existsSync(path.join(entryPath, 'FileStorage', 'Image')) || + fs.existsSync(path.join(entryPath, 'FileStorage', 'Image2')) + ) + } + + private pushAccountIdCandidates(candidates: string[], value?: string): void { + const raw = String(value || '').trim() + if (!this.isReasonableAccountId(raw)) return + for (const candidate of [raw, this.normalizeAccountId(raw)]) { + if (candidate && !candidates.includes(candidate) && this.isReasonableAccountId(candidate)) { + candidates.push(candidate) + } + } + } + + 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 isReasonableAccountId(value: string): boolean { + const lowered = String(value || '').trim().toLowerCase() + if (!lowered || lowered.includes('/') || lowered.includes('\\')) return false + return !['xwechat_files', 'all_users', 'backup', 'wmpf', 'app_data'].includes(lowered) + } + + private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { + const xorKey = code & 0xff + const aesKey = crypto + .createHash('md5') + .update(`${code}${this.normalizeAccountId(wxid)}`) + .digest('hex') + .substring(0, 16) + return { xorKey, aesKey } + } + + private findTemplateData(userDir: string, limit = 32): { ciphertext: Buffer | null } { + const magic = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + const files: string[] = [] + const collect = (dir: string): void => { + if (files.length >= limit) return + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (files.length >= limit) break + const full = path.join(dir, entry.name) + if (entry.isDirectory()) collect(full) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) files.push(full) + } + } catch { + // Ignore unreadable directories. + } + } + collect(userDir) + files.sort((a, b) => { + try { + return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs + } catch { + return 0 + } + }) + + for (const file of files) { + try { + const data = fs.readFileSync(file) + if (data.length >= 0x1f && data.subarray(0, 6).equals(magic)) { + return { ciphertext: data.subarray(0x0f, 0x1f) } + } + } catch { + // Try the next file. + } + } + return { ciphertext: null } + } + + private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { + try { + if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false + const decipher = crypto.createDecipheriv( + 'aes-128-ecb', + Buffer.from(aesKey, 'ascii').subarray(0, 16), + null + ) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + 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 + } + } } diff --git a/src/main/key-service-win.ts b/src/main/key-service-win.ts index 54cecf0..4f0d90c 100644 --- a/src/main/key-service-win.ts +++ b/src/main/key-service-win.ts @@ -922,64 +922,8 @@ export class KeyService { onProgress?: (message: string) => void, wxidParam?: string ): Promise { - if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } - if (!this.ensureLoaded()) return { success: false, error: this.getLoadError() } - - onProgress?.('正在从缓存目录扫描图片密钥...') - - const resultBuffer = Buffer.alloc(8192) - const ok = this.getImageKeyDll(resultBuffer, resultBuffer.length) - - if (!ok) { - const errMsg = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '获取图片密钥失败' - return { success: false, error: errMsg } - } - - const jsonStr = this.decodeUtf8(resultBuffer) - let parsed: any - try { - parsed = JSON.parse(jsonStr) - } catch { - return { success: false, error: '解析密钥数据失败' } - } - - // 从任意账号提取 code 列表(code 来自 kvcomm,与 wxid 无关,所有账号都一样) - const accounts: any[] = parsed.accounts ?? [] - if (!accounts.length || !accounts[0]?.keys?.length) { - return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' } - } - - const codes: number[] = accounts[0].keys.map((k: any) => k.code) - console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid)) - - const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam) - let verifyCiphertext: Buffer | null = null - if (manualDir && existsSync(manualDir)) { - const template = await this._findTemplateData(manualDir, 32) - verifyCiphertext = template.ciphertext - } - - if (verifyCiphertext) { - onProgress?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) - for (const candidateWxid of wxidCandidates) { - for (const code of codes) { - const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) - if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue - onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) - console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code) - return { success: true, xorKey, aesKey, verified: true } - } - } - return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } - } - - // 无模板密文可验真时回退旧策略 - const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown' - const fallbackCode = codes[0] - const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) - onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) - console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode) - return { success: true, xorKey, aesKey, verified: false } + void wxidParam + return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress) } // --- 内存扫描备选方案(融合 Dart+Python 优点)--- diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index c82de47..6404fa7 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -8,6 +8,9 @@ export interface AppSettings { apiEnabled: boolean apiHost: string apiPort: number + imageKeyRoot: string + imageXorKey: string + imageAesKey: string } function getDefaultDbRoot(): string { @@ -102,11 +105,16 @@ function isUsableDbRoot(candidate?: string): boolean { } } +const defaultDbRoot = getDefaultDbRoot() + const DEFAULT_SETTINGS: AppSettings = { - dbRoot: getDefaultDbRoot(), + dbRoot: defaultDbRoot, apiEnabled: true, apiHost: '127.0.0.1', - apiPort: 6131 + apiPort: 6131, + imageKeyRoot: defaultDbRoot, + imageXorKey: process.env.VITE_IMAGE_XOR_KEY || '', + imageAesKey: process.env.VITE_IMAGE_AES_KEY || '' } const SETTINGS_FILE = path.join( @@ -129,6 +137,9 @@ export function loadSettings(): AppSettings { if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) { cache.dbRoot = getDefaultDbRoot() } + if (!cache.imageKeyRoot) { + cache.imageKeyRoot = cache.dbRoot + } return cache } } catch (error) { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index bdc10ec..71f7abc 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -78,17 +78,39 @@ declare global { saved?: boolean warning?: string }> + autoGetImageKey: () => Promise<{ + success: boolean + xorKey?: number + aesKey?: string + verified?: boolean + error?: string + imageXorKey?: string + imageAesKey?: string + settings?: { + dbRoot: string + apiEnabled: boolean + apiHost: string + apiPort: number + imageKeyRoot: string + imageXorKey: string + imageAesKey: string + } + }> pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> saveDbKey: (key: string) => Promise<{ success: boolean; key?: string; error?: string }> clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void + onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void getSettings: () => Promise<{ settings: { dbRoot: string apiEnabled: boolean apiHost: string apiPort: number + imageKeyRoot: string + imageXorKey: string + imageAesKey: string } settingsPath: string }> @@ -97,12 +119,18 @@ declare global { apiEnabled: boolean apiHost: string apiPort: number + imageKeyRoot: string + imageXorKey: string + imageAesKey: string }>) => Promise<{ settings: { dbRoot: string apiEnabled: boolean apiHost: string apiPort: number + imageKeyRoot: string + imageXorKey: string + imageAesKey: string } settingsPath: string }> diff --git a/src/preload/index.ts b/src/preload/index.ts index b5cc046..67d0d58 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,6 +28,7 @@ const api = { revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), + autoGetImageKey: () => ipcRenderer.invoke('key:autoGetImageKey'), pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'), saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key), clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'), @@ -45,6 +46,12 @@ const api = { ipcRenderer.on('key:dbKeyStatus', listener) return () => ipcRenderer.removeListener('key:dbKeyStatus', listener) }, + onImageKeyStatus: (callback: (payload: { message: string }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void => + callback(payload) + ipcRenderer.on('key:imageKeyStatus', listener) + return () => ipcRenderer.removeListener('key:imageKeyStatus', listener) + }, getSettings: () => ipcRenderer.invoke('settings:get'), setSettings: (patch) => ipcRenderer.invoke('settings:set', patch), getSelf: () => ipcRenderer.invoke('settings:getSelf'), diff --git a/src/renderer/src/components/SettingsPanel.tsx b/src/renderer/src/components/SettingsPanel.tsx index 257da63..01d550a 100644 --- a/src/renderer/src/components/SettingsPanel.tsx +++ b/src/renderer/src/components/SettingsPanel.tsx @@ -12,6 +12,9 @@ interface AppSettings { apiEnabled: boolean apiHost: string apiPort: number + imageKeyRoot: string + imageXorKey: string + imageAesKey: string } interface ApiState { @@ -51,6 +54,10 @@ export const SettingsPanel: React.FC = ({ { kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string } >({ kind: 'idle', message: '' }) const [reopenStatus, setReopenStatus] = useState('') + const [imageKeyStatus, setImageKeyStatus] = useState<{ + kind: 'idle' | 'ok' | 'fail' + message: string + }>({ kind: 'idle', message: '' }) const [busy, setBusy] = useState(false) useEffect(() => { @@ -58,6 +65,13 @@ export const SettingsPanel: React.FC = ({ void refresh() }, [open]) + useEffect(() => { + if (!open) return + return window.api.onImageKeyStatus(({ message }) => { + setImageKeyStatus({ kind: 'idle', message }) + }) + }, [open]) + async function refresh(): Promise { const [{ settings, settingsPath }, api] = await Promise.all([ window.api.getSettings(), @@ -140,6 +154,37 @@ export const SettingsPanel: React.FC = ({ setBusy(false) } + async function handleAutoGetImageKey(): Promise { + if (!settings) return + setBusy(true) + setImageKeyStatus({ kind: 'idle', message: '正在扫描微信内存获取图片密钥...' }) + try { + const result = await window.api.autoGetImageKey() + if (!result.success || !result.aesKey) { + setImageKeyStatus({ kind: 'fail', message: result.error || '图片密钥获取失败' }) + return + } + const imageXorKey = + result.imageXorKey || + (typeof result.xorKey === 'number' + ? `0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}` + : settings.imageXorKey) + const imageAesKey = result.imageAesKey || result.aesKey + setSettings(result.settings || { ...settings, imageXorKey, imageAesKey }) + setImageKeyStatus({ + kind: 'ok', + message: result.verified ? '图片密钥已获取并校验通过' : '图片密钥已获取,未完成模板校验' + }) + } catch (error) { + setImageKeyStatus({ + kind: 'fail', + message: error instanceof Error ? error.message : String(error) + }) + } finally { + setBusy(false) + } + } + return (
e.stopPropagation()}> @@ -216,6 +261,60 @@ export const SettingsPanel: React.FC = ({
+ {/* 图片解密密钥 */} +
+
图片解密密钥
+
+ + setSettings(settings ? { ...settings, imageKeyRoot: e.target.value } : null) + } + onBlur={(e) => handleSave({ imageKeyRoot: e.target.value })} + placeholder={dbRootPlaceholder} + spellCheck={false} + /> +
+
+ + setSettings(settings ? { ...settings, imageXorKey: e.target.value } : null) + } + onBlur={(e) => handleSave({ imageXorKey: e.target.value })} + placeholder="XOR Key,如 0x40" + spellCheck={false} + /> + + setSettings(settings ? { ...settings, imageAesKey: e.target.value } : null) + } + onBlur={(e) => handleSave({ imageAesKey: e.target.value })} + placeholder="AES Key,16 位字符" + spellCheck={false} + /> + +
+ {imageKeyStatus.message && ( +
+ {imageKeyStatus.kind === 'ok' ? '✓ ' : imageKeyStatus.kind === 'fail' ? '✗ ' : ''} + {imageKeyStatus.message} +
+ )} +
+ 目录默认使用数据库根目录,用于查找图片模板文件。Windows 会直接扫描微信内存,请先在微信中打开 2-3 张图片大图。 +
+
+ {/* 数据库根目录 */}
数据库根目录