feat: 支持图片密钥配置与内存扫描

This commit is contained in:
电摇小子
2026-07-14 10:20:23 +08:00
committed by 电摇小子
parent 8d82fabf57
commit 71e970c55c
11 changed files with 490 additions and 67 deletions
+1 -1
View File
@@ -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=
+3 -2
View File
@@ -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
+3
View File
@@ -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",
+13
View File
@@ -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')
+57 -4
View File
@@ -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<AppSettings>) => {
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() }
})
+264
View File
@@ -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<ImageKeyResult> {
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<number>()
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<string>([
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
}
}
}
+2 -58
View File
@@ -922,64 +922,8 @@ export class KeyService {
onProgress?: (message: string) => void,
wxidParam?: string
): Promise<ImageKeyResult> {
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 优点)---
+13 -2
View File
@@ -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) {
+28
View File
@@ -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
}>
+7
View File
@@ -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'),
@@ -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<SettingsPanelProps> = ({
{ kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string }
>({ kind: 'idle', message: '' })
const [reopenStatus, setReopenStatus] = useState<string>('')
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<SettingsPanelProps> = ({
void refresh()
}, [open])
useEffect(() => {
if (!open) return
return window.api.onImageKeyStatus(({ message }) => {
setImageKeyStatus({ kind: 'idle', message })
})
}, [open])
async function refresh(): Promise<void> {
const [{ settings, settingsPath }, api] = await Promise.all([
window.api.getSettings(),
@@ -140,6 +154,37 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
setBusy(false)
}
async function handleAutoGetImageKey(): Promise<void> {
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 (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
@@ -216,6 +261,60 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
</div>
</section>
{/* 图片解密密钥 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={settings?.imageKeyRoot || settings?.dbRoot || ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageKeyRoot: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageKeyRoot: e.target.value })}
placeholder={dbRootPlaceholder}
spellCheck={false}
/>
</div>
<div className="settings-row">
<input
type="text"
className="settings-input settings-input-quarter"
value={settings?.imageXorKey ?? ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageXorKey: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageXorKey: e.target.value })}
placeholder="XOR Key,如 0x40"
spellCheck={false}
/>
<input
type="text"
className="settings-input"
value={settings?.imageAesKey ?? ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageAesKey: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageAesKey: e.target.value })}
placeholder="AES Key16 位字符"
spellCheck={false}
/>
<button className="settings-btn" onClick={handleAutoGetImageKey} disabled={busy}>
</button>
</div>
{imageKeyStatus.message && (
<div className={`settings-status ${imageKeyStatus.kind}`}>
{imageKeyStatus.kind === 'ok' ? '✓ ' : imageKeyStatus.kind === 'fail' ? '✗ ' : ''}
{imageKeyStatus.message}
</div>
)}
<div className="settings-hint">
使Windows 2-3
</div>
</section>
{/* 数据库根目录 */}
<section className="settings-section">
<div className="settings-section-title"></div>