diff --git a/src/main/image-key-store.ts b/src/main/image-key-store.ts new file mode 100644 index 0000000..3d40f52 --- /dev/null +++ b/src/main/image-key-store.ts @@ -0,0 +1,99 @@ +import { app, safeStorage } from 'electron' +import fs from 'fs-extra' +import path from 'path' + +export interface StoredImageKeyEntry { + xorKey: string + aesKey: string + updatedAt: number +} + +interface StoredImageKeyFile { + version: 1 + accounts: Record +} + +interface StoreReadResult { + success: boolean + data?: StoredImageKeyFile + error?: string + encryptionAvailable: boolean +} + +export class ImageKeyStore { + private get filePath(): string { + return path.join(app.getPath('userData'), 'wechat-image-keys.bin') + } + + get(accountId: string): StoreReadResult & { entry?: StoredImageKeyEntry } { + const result = this.read() + return { ...result, entry: result.data?.accounts[accountId] } + } + + save( + accountId: string, + entry: Omit + ): { success: boolean; entry?: StoredImageKeyEntry; error?: string } { + if (!safeStorage.isEncryptionAvailable()) { + return { success: false, error: '系统安全存储不可用' } + } + const current = this.read() + if (!current.success && fs.existsSync(this.filePath)) { + return { success: false, error: '无法读取现有图片密钥配置' } + } + const nextEntry = { ...entry, updatedAt: Date.now() } + const data: StoredImageKeyFile = current.data || { version: 1, accounts: {} } + data.accounts[accountId] = nextEntry + const saved = this.write(data) + return saved.success ? { success: true, entry: nextEntry } : saved + } + + clear(accountId: string): { success: boolean; error?: string } { + const current = this.read() + if (!current.success) return { success: false, error: current.error } + if (!current.data?.accounts[accountId]) return { success: true } + delete current.data.accounts[accountId] + try { + if (Object.keys(current.data.accounts).length === 0) fs.removeSync(this.filePath) + else return this.write(current.data) + return { success: true } + } catch { + return { success: false, error: '无法清除图片密钥配置' } + } + } + + private read(): StoreReadResult { + const encryptionAvailable = safeStorage.isEncryptionAvailable() + if (!fs.existsSync(this.filePath)) { + return { + success: true, + data: { version: 1, accounts: {} }, + encryptionAvailable + } + } + if (!encryptionAvailable) { + return { success: false, error: '系统安全存储不可用', encryptionAvailable } + } + try { + const decrypted = safeStorage.decryptString(fs.readFileSync(this.filePath)) + const data = JSON.parse(decrypted) as StoredImageKeyFile + if (data.version !== 1 || !data.accounts) throw new Error('invalid image key store') + return { success: true, data, encryptionAvailable } + } catch { + return { success: false, error: '图片密钥安全存储不可读取', encryptionAvailable } + } + } + + private write(data: StoredImageKeyFile): { success: boolean; error?: string } { + try { + fs.ensureDirSync(path.dirname(this.filePath)) + fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), { + mode: 0o600 + }) + fs.chmodSync(this.filePath, 0o600) + return { success: true } + } catch { + return { success: false, error: '图片密钥保存失败' } + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 740ad80..7bbee53 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,6 +28,7 @@ import { import type { GroupReportExportRequest } from '../shared/group-report' import type { SaveGeneratedReportRequest } from '../shared/report-history' import { DatabaseKeyStore } from './database-key-store' +import { ImageKeyConfigService } from './services/image-key-config-service' import { KeyServiceMac } from './key-service-mac' import { KeyService as KeyServiceWin } from './key-service-win' import * as chat from './services/chat-service' @@ -35,6 +36,11 @@ import { apiServer } from './http-server' import { skillResourceService } from './services/skill-resource-service' import { testLocalApiRequest } from './services/local-api-test-service' import { isWindowsWechatRunning } from './services/wechat-process-status' +import { + inspectImageDecryptionStatus, + testImageDecryption +} from './services/image-decryption-status-service' +import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption' import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store' import { getBootstrapCache, @@ -56,6 +62,7 @@ let voiceService: VoiceService | null = null let imageDecryptService: ImageDecryptService | null = null let stickerService: StickerService | null = null const databaseKeyStore = new DatabaseKeyStore() +const imageKeyConfigService = new ImageKeyConfigService() const keyServiceMac = new KeyServiceMac() const keyServiceWin = new KeyServiceWin() let tray: Tray | null = null @@ -69,24 +76,11 @@ 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() + const config = imageKeyConfigService.getConfig() return { - xorKey: settings.imageXorKey || import.meta.env.VITE_IMAGE_XOR_KEY || '0x40', - aesKey: settings.imageAesKey || import.meta.env.VITE_IMAGE_AES_KEY || '' + xorKey: config.xorKey || '0x40', + aesKey: config.aesKey || '' } } @@ -247,7 +241,7 @@ app.whenReady().then(async () => { } }) - ipcMain.handle('key:autoGetImageKey', async (event) => { + ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => { const settings = loadSettings() const self = chat.getSelfAccountInfo() const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot @@ -261,22 +255,57 @@ app.whenReady().then(async () => { : await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid) if (!result.success || !result.aesKey) return result + if (process.platform === 'win32') { + onStatus('发现候选密钥,图片模板验证通过') + } - const imageXorKey = normalizeImageXorKey(result.xorKey) - const nextSettings = saveSettings({ - ...settings, - imageXorKey, - imageAesKey: result.aesKey + const imageXorKey = `0x${Number(result.xorKey ?? 0x40) + .toString(16) + .toUpperCase() + .padStart(2, '0')}` + const verified = result.verified ?? process.platform === 'win32' + if (options?.save === false) { + return { ...result, verified, imageXorKey, imageAesKey: result.aesKey } + } + const saved = imageKeyConfigService.save({ + resourceRoot: accountRoot, + xorKey: imageXorKey, + aesKey: result.aesKey }) - imageDecryptService = null + if (saved.success) imageDecryptService = null return { ...result, + success: saved.success, + error: saved.success ? undefined : saved.error, + verified, imageXorKey, imageAesKey: result.aesKey, - settings: nextSettings + settings: imageKeyConfigService.getLegacySettingsView() } }) + ipcMain.handle('image:getConfig', () => imageKeyConfigService.getConfig()) + + ipcMain.handle('image:getStatus', async () => + inspectImageDecryptionStatus(imageKeyConfigService.getConfig()) + ) + + ipcMain.handle('image:saveConfig', (_, request: SaveImageKeyRequest) => { + const result = imageKeyConfigService.save(request) + if (result.success) imageDecryptService = null + return result + }) + + ipcMain.handle('image:testConfig', (_, request: TestImageDecryptionRequest) => + testImageDecryption(request) + ) + + ipcMain.handle('image:clearConfig', () => { + const result = imageKeyConfigService.clear() + if (result.success) imageDecryptService = null + return result + }) + ipcMain.handle('db:getBootstrapCache', () => { if (!chat.isReady()) return null return getBootstrapCache(chat.getCurrentAccountRoot()) @@ -481,17 +510,29 @@ app.whenReady().then(async () => { // -------- Settings & API service -------- ipcMain.handle('settings:get', () => ({ - settings: loadSettings(), + settings: imageKeyConfigService.getLegacySettingsView(), settingsPath: getSettingsPath() })) ipcMain.handle('settings:set', (_, patch: Partial) => { const before = loadSettings() - const merged = saveSettings({ ...before, ...patch }) - if (before.imageXorKey !== merged.imageXorKey || before.imageAesKey !== merged.imageAesKey) { + const current = imageKeyConfigService.getConfig() + const resourceRoot = patch.imageKeyRoot ?? before.imageKeyRoot + const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40' + const aesKey = patch.imageAesKey ?? current.aesKey ?? '' + const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch + if (includesImageKey) { + saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' }) + if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey }) + else imageKeyConfigService.clear() imageDecryptService = null + } else { + saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' }) + } + return { + settings: imageKeyConfigService.getLegacySettingsView(), + settingsPath: getSettingsPath() } - return { settings: merged, settingsPath: getSettingsPath() } }) ipcMain.handle('settings:getSelf', () => { diff --git a/src/main/services/image-decryption-status-service.ts b/src/main/services/image-decryption-status-service.ts new file mode 100644 index 0000000..75f92bd --- /dev/null +++ b/src/main/services/image-decryption-status-service.ts @@ -0,0 +1,139 @@ +import { app } from 'electron' +import fs from 'fs-extra' +import os from 'os' +import path from 'path' +import type { + ImageDecryptionStatus, + ImageDecryptionTestResult, + ImageKeyConfigResult, + ImageResourceCheck, + TestImageDecryptionRequest +} from '../../shared/image-decryption' +import { ImageDecryptService } from '../image-decrypt-service' +import * as chat from './chat-service' +import { validateImageKeyRequest } from './image-key-config-service' +import { isWechatRunning } from './wechat-process-status' + +export async function inspectImageDecryptionStatus( + config: ImageKeyConfigResult +): Promise { + const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot + const imageDirectoryFound = hasImageDirectory(accountRoot) + const stickerCacheFound = + fs.existsSync(path.join(accountRoot, 'cache')) || + fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')) + const dbConnected = chat.isReady() + + return { + configured: config.configured, + saved: config.saved, + encryptionAvailable: config.encryptionAvailable, + source: config.source, + accountId: config.accountId, + resourceRoot: accountRoot, + updatedAt: config.updatedAt, + platform: process.platform, + autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin', + wechatRunning: await isWechatRunning(), + accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid), + cacheState: canUseCacheRoot() ? 'normal' : 'unavailable', + resources: { + imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'), + imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'), + thumbnail: pending(imageDirectoryFound), + original: pending(imageDirectoryFound), + sticker: stickerCacheFound + ? check(true, '本地缓存可用') + : { state: 'unknown', detail: '独立按需解析' }, + video: { state: 'unavailable', detail: '当前版本未提供视频媒体解析' } + } + } +} + +export function testImageDecryption( + request: TestImageDecryptionRequest +): ImageDecryptionTestResult { + const normalized = validateImageKeyRequest(request) + if (!normalized.success) return failure('NOT_CONFIGURED', normalized.error) + if (!chat.isReady() || !request.userMd5) { + return failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录') + } + + try { + const messages = chat.listMessages(request.userMd5, undefined, undefined, { limit: 300 }) + const imageMessage = [...messages] + .reverse() + .find((message) => message.contentData?.type === 'image') + if (!imageMessage || imageMessage.contentData?.type !== 'image') { + return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息') + } + + const service = new ImageDecryptService( + normalized.xorKey, + normalized.aesKey, + chat.getChatDb()?.getWcdb4Client() + ) + const image = imageMessage.contentData + let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false }) + if (!filePath) + filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true }) + if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在') + + const data = service.decryptImageToBase64(filePath) + if (!data) { + return { + ...failure('DECRYPT_FAILED', '无法解析媒体文件'), + fileFound: true + } + } + const readable = data.startsWith('data:image/') + return { + success: readable, + code: readable ? undefined : 'DECRYPT_FAILED', + error: readable ? undefined : '图片解密结果不可读取', + fileFound: true, + decrypted: true, + readable, + isThumbnail: service.isThumbnailFile(filePath) + } + } catch { + return failure('UNKNOWN', '图片解析测试未通过') + } +} + +function hasImageDirectory(accountRoot: string): boolean { + if (!accountRoot) return false + return [ + path.join(accountRoot, 'msg', 'attach'), + path.join(accountRoot, 'FileStorage', 'Image'), + path.join(accountRoot, 'FileStorage', 'Image2'), + path.join(accountRoot, 'FileStorage', 'MsgImg') + ].some((candidate) => fs.existsSync(candidate)) +} + +function canUseCacheRoot(): boolean { + try { + const cacheRoot = path.join(app.getPath('userData'), 'cache') + fs.ensureDirSync(cacheRoot) + return fs.statSync(cacheRoot).isDirectory() + } catch { + return false + } +} + +function check(available: boolean, detail: string): ImageResourceCheck { + return { state: available ? 'available' : 'unavailable', detail } +} + +function pending(directoryFound: boolean): ImageResourceCheck { + return directoryFound + ? { state: 'unknown', detail: '通过图片解析测试确认' } + : { state: 'unavailable', detail: '图片目录不可用' } +} + +function failure( + code: NonNullable, + error: string +): ImageDecryptionTestResult { + return { success: false, code, error, fileFound: false, decrypted: false, readable: false } +} diff --git a/src/main/services/image-key-config-service.ts b/src/main/services/image-key-config-service.ts new file mode 100644 index 0000000..a7bc547 --- /dev/null +++ b/src/main/services/image-key-config-service.ts @@ -0,0 +1,207 @@ +import path from 'path' +import type { ImageKeyConfigResult, SaveImageKeyRequest } from '../../shared/image-decryption' +import { ImageKeyStore } from '../image-key-store' +import * as chat from './chat-service' +import { loadSettings, saveSettings, type AppSettings } from './settings-store' + +export class ImageKeyConfigService { + constructor(private readonly store = new ImageKeyStore()) {} + + getConfig(): ImageKeyConfigResult { + const settings = loadSettings() + const context = this.getContext(settings) + const stored = this.store.get(context.accountId) + if (!stored.success) { + return { + success: false, + configured: false, + saved: false, + encryptionAvailable: stored.encryptionAvailable, + source: 'none', + accountId: context.accountId, + resourceRoot: context.resourceRoot, + error: stored.error + } + } + if (stored.entry) { + return { + success: true, + configured: true, + saved: true, + encryptionAvailable: stored.encryptionAvailable, + source: 'secure-storage', + accountId: context.accountId, + resourceRoot: context.resourceRoot, + xorKey: stored.entry.xorKey, + aesKey: stored.entry.aesKey, + updatedAt: stored.entry.updatedAt + } + } + + if (settings.imageAesKey.trim()) { + const legacy = this.buildResult({ + source: 'legacy-settings', + xorKey: settings.imageXorKey || '0x40', + aesKey: settings.imageAesKey, + context, + encryptionAvailable: stored.encryptionAvailable + }) + if (!stored.encryptionAvailable) return legacy + const migrated = this.save({ + resourceRoot: context.resourceRoot, + xorKey: legacy.xorKey || '0x40', + aesKey: legacy.aesKey || '' + }) + return migrated.success ? this.getConfig() : legacy + } + + const envAesKey = String(import.meta.env.VITE_IMAGE_AES_KEY || '').trim() + if (!settings.imageKeyFallbackDisabled && envAesKey) { + return this.buildResult({ + source: 'environment', + xorKey: String(import.meta.env.VITE_IMAGE_XOR_KEY || '0x40'), + aesKey: envAesKey, + context, + encryptionAvailable: stored.encryptionAvailable + }) + } + + return { + success: true, + configured: false, + saved: false, + encryptionAvailable: stored.encryptionAvailable, + source: 'none', + accountId: context.accountId, + resourceRoot: context.resourceRoot + } + } + + save(request: SaveImageKeyRequest): ImageKeyConfigResult { + const normalized = validateImageKeyRequest(request) + if (!normalized.success) return { ...this.getEmptyConfig(), error: normalized.error } + const settings = loadSettings() + const context = this.getContext(settings) + if (!chat.getSelfAccountInfo()?.wxid) { + return { ...this.getEmptyConfig(), error: '当前微信账号尚未识别' } + } + const result = this.store.save(context.accountId, { + xorKey: normalized.xorKey, + aesKey: normalized.aesKey + }) + if (!result.success || !result.entry) { + return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' } + } + saveSettings({ + ...settings, + imageKeyRoot: normalized.resourceRoot, + imageXorKey: '', + imageAesKey: '', + imageKeyFallbackDisabled: false + }) + return { + success: true, + configured: true, + saved: true, + encryptionAvailable: true, + source: 'secure-storage', + accountId: context.accountId, + resourceRoot: normalized.resourceRoot, + xorKey: result.entry.xorKey, + aesKey: result.entry.aesKey, + updatedAt: result.entry.updatedAt + } + } + + clear(): { success: boolean; error?: string } { + const settings = loadSettings() + const context = this.getContext(settings) + const cleared = this.store.clear(context.accountId) + if (!cleared.success) return cleared + saveSettings({ + ...settings, + imageXorKey: '', + imageAesKey: '', + imageKeyFallbackDisabled: true + }) + return { success: true } + } + + getLegacySettingsView(): AppSettings { + const settings = loadSettings() + const config = this.getConfig() + return { + ...settings, + imageXorKey: config.xorKey || '', + imageAesKey: config.aesKey || '' + } + } + + private getContext(settings: AppSettings): { accountId: string; resourceRoot: string } { + const self = chat.getSelfAccountInfo() + const accountRoot = self?.accountRoot || chat.getCurrentAccountRoot() || settings.dbRoot + return { + accountId: self?.wxid || path.basename(accountRoot || '') || 'unbound', + resourceRoot: settings.imageKeyRoot || accountRoot || settings.dbRoot + } + } + + private buildResult(input: { + source: 'legacy-settings' | 'environment' + xorKey: string + aesKey: string + context: { accountId: string; resourceRoot: string } + encryptionAvailable: boolean + }): ImageKeyConfigResult { + return { + success: true, + configured: true, + saved: false, + encryptionAvailable: input.encryptionAvailable, + source: input.source, + accountId: input.context.accountId, + resourceRoot: input.context.resourceRoot, + xorKey: normalizeImageXorKey(input.xorKey), + aesKey: input.aesKey.trim() + } + } + + private getEmptyConfig(): ImageKeyConfigResult { + const settings = loadSettings() + const context = this.getContext(settings) + const stored = this.store.get(context.accountId) + return { + success: false, + configured: false, + saved: false, + encryptionAvailable: stored.encryptionAvailable, + source: 'none', + accountId: context.accountId, + resourceRoot: context.resourceRoot + } + } +} + +export function normalizeImageXorKey(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '0x40' + const parsed = raw.toLowerCase().startsWith('0x') + ? Number.parseInt(raw.slice(2), 16) + : Number.parseInt(raw, 10) + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 255) return raw + return `0x${parsed.toString(16).toUpperCase().padStart(2, '0')}` +} + +export function validateImageKeyRequest( + request: SaveImageKeyRequest +): + | { success: true; resourceRoot: string; xorKey: string; aesKey: string } + | { success: false; error: string } { + const resourceRoot = request.resourceRoot.trim() + const xorKey = normalizeImageXorKey(request.xorKey) + const aesKey = request.aesKey.trim() + if (!resourceRoot) return { success: false, error: '图片资源目录不能为空' } + if (!/^0x[0-9A-F]{2}$/.test(xorKey)) return { success: false, error: 'XOR Key 格式不正确' } + if (aesKey.length !== 16) return { success: false, error: 'AES Key 必须为 16 个字符' } + return { success: true, resourceRoot, xorKey, aesKey } +} diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 6404fa7..b6a14d5 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -11,6 +11,7 @@ export interface AppSettings { imageKeyRoot: string imageXorKey: string imageAesKey: string + imageKeyFallbackDisabled: boolean } function getDefaultDbRoot(): string { @@ -21,7 +22,9 @@ function getDefaultDbRoot(): string { function getDefaultDbRootCandidates(home: string): string[] { if (process.platform !== 'win32') { - return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')] + return [ + path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files') + ] } const candidates = [ @@ -113,8 +116,9 @@ const DEFAULT_SETTINGS: AppSettings = { apiHost: '127.0.0.1', apiPort: 6131, imageKeyRoot: defaultDbRoot, - imageXorKey: process.env.VITE_IMAGE_XOR_KEY || '', - imageAesKey: process.env.VITE_IMAGE_AES_KEY || '' + imageXorKey: '', + imageAesKey: '', + imageKeyFallbackDisabled: false } const SETTINGS_FILE = path.join( diff --git a/src/main/services/wechat-process-status.ts b/src/main/services/wechat-process-status.ts index 2f3c17c..8afcc2d 100644 --- a/src/main/services/wechat-process-status.ts +++ b/src/main/services/wechat-process-status.ts @@ -21,3 +21,21 @@ export async function isWindowsWechatRunning(): Promise { } return false } + +export async function isWechatRunning(): Promise { + if (process.platform === 'win32') return isWindowsWechatRunning() + if (process.platform !== 'darwin') return false + + for (const args of [ + ['-x', 'WeChat'], + ['-f', 'WeChat.app/Contents/MacOS/WeChat'] + ]) { + try { + const { stdout } = await execFileAsync('/usr/bin/pgrep', args) + if (stdout.trim()) return true + } catch { + // Try the next macOS process lookup strategy. + } + } + return false +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 2e270e8..09dbe91 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -13,6 +13,13 @@ import type { DatabaseKeyStorageResult, DatabaseKeyValidationResult } from '../shared/database-key' +import type { + ImageDecryptionStatus, + ImageDecryptionTestResult, + ImageKeyConfigResult, + SaveImageKeyRequest, + TestImageDecryptionRequest +} from '../shared/image-decryption' export type ParsedContent = | { type: 'text'; content: string } @@ -133,7 +140,7 @@ declare global { saved?: boolean warning?: string }> - autoGetImageKey: () => Promise<{ + autoGetImageKey: (options?: { save?: boolean }) => Promise<{ success: boolean xorKey?: number aesKey?: string @@ -151,6 +158,13 @@ declare global { imageAesKey: string } }> + getImageKeyConfig: () => Promise + getImageDecryptionStatus: () => Promise + saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise + testImageDecryption: ( + request: TestImageDecryptionRequest + ) => Promise + clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }> pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> saveDbKey: (key: string) => Promise clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> diff --git a/src/preload/index.ts b/src/preload/index.ts index 3499b53..e865cf4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -47,7 +47,13 @@ const api = { getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'), readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'), autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options), - autoGetImageKey: () => ipcRenderer.invoke('key:autoGetImageKey'), + autoGetImageKey: (options?: { save?: boolean }) => + ipcRenderer.invoke('key:autoGetImageKey', options), + getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'), + getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'), + saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request), + testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request), + clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'), pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'), saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key), clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index d192f26..8155f10 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -3645,6 +3645,15 @@ body { .database-key-diagnostics { margin-top:22px; border:1px solid #dde3e0; border-radius:9px; background:#fff; }.database-key-diagnostics summary { padding:14px 16px; color:#46514c; cursor:pointer; font-size:13px; font-weight:600; }.database-key-diagnostics dl { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px 28px; margin:0; padding:2px 16px 16px; }.database-key-diagnostics button { margin:0 16px 16px; border:0; padding:0; background:transparent; color:#247a63; cursor:pointer; font:600 12px inherit; } .database-key-danger { margin-top:30px; padding-bottom:1px; }.database-key-danger>h2 { margin:0 0 14px; color:#c85a5a; font-size:15px; }.database-key-danger>div { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 20px; border:1px solid #efcdcd; background:#fff7f7; }.database-key-danger>div:first-of-type { border-radius:9px 9px 0 0; }.database-key-danger>div:last-of-type { border-top:0; border-radius:0 0 9px 9px; }.database-key-danger span { display:grid; gap:4px; }.database-key-danger strong { color:#4a3c3c; font-size:13px; }.database-key-danger small { color:#786868; font-size:12px; }.database-key-danger button { min-height:34px; flex:0 0 auto; border:1px solid #e8baba; border-radius:8px; padding:0 13px; background:#fff; color:#c85a5a; cursor:pointer; }.database-key-danger button:disabled { cursor:not-allowed; opacity:.45; } .database-key-confirm-backdrop { position:fixed; z-index:30; inset:0; display:grid; place-items:center; padding:24px; background:rgba(25,32,29,.34); }.database-key-confirm { width:min(100%,430px); padding:22px; border-radius:12px; background:#fff; box-shadow:0 18px 50px rgba(24,35,30,.2); }.database-key-confirm h2 { margin:0; color:#202724; font-size:17px; }.database-key-confirm p { margin:12px 0 22px; color:#66706b; font-size:13px; line-height:1.7; }.database-key-confirm>div { display:flex; justify-content:flex-end; gap:8px; }.database-key-confirm button { min-height:34px; border:1px solid #dde3e0; border-radius:8px; padding:0 14px; background:#fff; color:#46514c; cursor:pointer; }.database-key-confirm button.danger { border-color:#c85a5a; background:#c85a5a; color:#fff; } +/* SETTINGS-03: image decryption */ +.image-decryption-content { padding-bottom:48px; } +.image-decrypt-badge.unconfigured { background:#f1f3f2; color:#66706b; }.image-decrypt-badge.partial { background:#fff6e8; color:#a56a24; } +.image-decrypt-status dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px 30px; margin:0 0 20px; }.image-decrypt-status dl div { min-width:0; }.image-decrypt-status dt { color:#929a96; font-size:12px; }.image-decrypt-status dd { overflow:hidden; margin:5px 0 0; color:#35403b; font-size:13px; text-overflow:ellipsis; white-space:nowrap; }.image-decrypt-status .image-decrypt-wide { grid-column:span 2; }.image-decrypt-mono { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }.image-state-success { color:#2e8b68!important; }.image-state-muted { color:#66706b!important; }.image-state-error { color:#c85a5a!important; } +.image-resource-checks { padding:10px 18px; }.image-resource-checks>div { display:flex; min-width:0; align-items:center; gap:11px; padding:13px 2px; border-bottom:1px solid #edf0ee; }.image-resource-checks>div:last-child { border-bottom:0; }.image-resource-checks strong { color:#3d4742; font-size:13px; }.image-resource-checks small { margin-left:auto; overflow:hidden; color:#66706b; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }.image-resource-icon { display:grid; width:18px; height:18px; flex:0 0 auto; place-items:center; border-radius:50%; background:#f1f3f2; color:#66706b; font-size:11px; font-weight:700; }.image-resource-icon.available { background:#e6f2ed; color:#2e8b68; }.image-resource-icon.unavailable { background:#f9eaea; color:#c85a5a; } +.image-key-editor { display:grid; gap:17px; }.image-key-editor label { display:grid; min-width:0; gap:8px; color:#46514c; font-size:12px; font-weight:600; }.image-key-editor input,.image-test-section select { box-sizing:border-box; width:100%; height:40px; border:1px solid #d5ddda; border-radius:8px; outline:0; padding:0 12px; background:#f4f7f5; color:#202724; font:12px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; }.image-key-editor input:focus,.image-test-section select:focus { border-color:#247a63; box-shadow:0 0 0 2px rgba(36,122,99,.09); }.image-key-grid { display:grid; grid-template-columns:180px minmax(0,1fr); gap:14px; }.image-key-editor>p { margin:0; color:#66706b; font-size:12px; line-height:1.6; } +.image-test-section>div:first-child strong,.image-auto-detect strong,.image-auto-unavailable strong { color:#35403b; font-size:14px; }.image-test-section>div:first-child p,.image-auto-detect p,.image-auto-unavailable p { margin:6px 0 0; color:#66706b; font-size:12px; line-height:1.6; }.image-test-section>label { display:block; margin:18px 0 8px; color:#46514c; font-size:12px; font-weight:600; }.image-test-actions { display:flex; gap:9px; margin-top:14px; }.image-test-result { display:flex; flex-wrap:wrap; gap:8px 18px; margin-top:14px; padding:12px 14px; border-radius:8px; color:#2e765d; font-size:12px; }.image-test-result.success { border:1px solid #bfded3; background:#eaf5f1; }.image-test-result.error { border:1px solid #efcaca; background:#fff2f2; color:#a84444; }.image-test-result p { width:100%; margin:2px 0 0; }.image-inline-error { margin:14px 0 0; color:#a84444; font-size:12px; } +.image-auto-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; }.image-auto-detect ul { display:flex; flex-wrap:wrap; gap:10px 22px; margin:17px 0 0; padding:14px 0 0; border-top:1px solid #edf0ee; list-style:none; color:#8b5c25; font-size:12px; }.image-auto-detect li::before { content:'○'; margin-right:6px; }.image-auto-detect li.ok { color:#2e8b68; }.image-auto-detect li.ok::before { content:'✓'; }.image-auto-progress { margin-top:12px!important; padding:9px 12px; border-radius:7px; background:#f1f4f3; }.image-auto-unavailable { padding-top:20px; padding-bottom:20px; }.image-key-danger>div { border-radius:9px!important; }.image-key-danger { margin-top:30px; } +.image-auto-phases { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin:18px 0 0; padding:0; counter-reset:image-auto-phase; list-style:none; }.image-auto-phases li { position:relative; padding-top:24px; color:#929a96; text-align:center; font-size:11px; }.image-auto-phases li::before { position:absolute; top:0; left:50%; display:grid; width:18px; height:18px; place-items:center; border:1px solid #d5ddda; border-radius:50%; background:#fff; content:counter(image-auto-phase); counter-increment:image-auto-phase; transform:translateX(-50%); }.image-auto-phases li.active { color:#247a63; }.image-auto-phases li.active::before { border-color:#247a63; background:#247a63; color:#fff; }.image-auto-error { margin:12px 0 0!important; padding:10px 12px; border-left:3px solid #c85a5a; background:#fff2f2; color:#a84444!important; }.image-auto-success { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:7px 20px; align-items:center; margin-top:14px; padding:14px 16px; border:1px solid #bfded3; border-radius:8px; background:#eaf5f1; color:#2e765d; font-size:12px; }.image-auto-success strong { grid-column:1; color:#2e765d; }.image-auto-success span { grid-column:1; }.image-auto-success button { grid-column:2; grid-row:1/4; } @keyframes settings-spin { to { transform:rotate(360deg); } } @media (max-width:900px) { .settings-sidebar { width:248px; flex-basis:248px; } @@ -3654,6 +3663,7 @@ body { .settings-account-facts { grid-column:1/-1; grid-template-columns:repeat(2,minmax(0,1fr)); } .settings-account-actions { grid-row:1; grid-column:2; } .database-key-security-info { grid-template-columns:1fr; } + .image-decrypt-status dl { grid-template-columns:repeat(2,minmax(0,1fr)); }.image-decrypt-status .image-decrypt-wide { grid-column:span 2; } } @media (max-width:700px) { .settings-account-overview { grid-template-columns:minmax(0,1fr); } @@ -3661,6 +3671,7 @@ body { .settings-account-root { grid-column:1; } .database-key-status-card dl,.database-key-diagnostics dl { grid-template-columns:1fr; } .database-key-auto-heading { flex-direction:column; }.database-key-phases { grid-template-columns:1fr; }.database-key-phases li { padding:0 0 0 28px; text-align:left; }.database-key-phases li::before { top:-2px; left:0; transform:none; } + .image-decrypt-status dl,.image-key-grid { grid-template-columns:1fr; }.image-decrypt-status .image-decrypt-wide { grid-column:1; }.image-auto-heading { flex-direction:column; }.image-auto-phases { grid-template-columns:1fr; }.image-auto-phases li { padding:0 0 0 28px; text-align:left; }.image-auto-phases li::before { top:-2px; left:0; transform:none; }.image-auto-success { grid-template-columns:1fr; }.image-auto-success button { grid-column:1; grid-row:auto; justify-self:start; }.image-resource-checks>div { align-items:flex-start; }.image-resource-checks small { white-space:normal; text-align:right; } } .api-center-layout > * { min-width: 0; min-height: 0; box-sizing: border-box; } .api-section-heading h2, .api-introduction h2, .api-integrations h2, .api-runtime-title h2 { margin: 0; color: var(--wxex-text-primary); font: 700 17px/24px var(--wxex-font); } diff --git a/src/renderer/src/features/settings/SettingsWorkspace.tsx b/src/renderer/src/features/settings/SettingsWorkspace.tsx index 4bb04de..ddf8efb 100644 --- a/src/renderer/src/features/settings/SettingsWorkspace.tsx +++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx @@ -4,6 +4,7 @@ import { SETTINGS_CATEGORY_LABELS } from './model/settingsNavigation' import type { SettingsCategoryId, SettingsSelfInfo } from './model/types' import { AccountDatabasePage } from './pages/AccountDatabasePage' import { DatabaseKeyPage } from './pages/DatabaseKeyPage' +import { ImageDecryptionPage } from './pages/ImageDecryptionPage' import type { Contact } from '../../../../shared/types' export function SettingsWorkspace({ @@ -61,6 +62,8 @@ export function SettingsWorkspace({ onFilteredContactsChange={onFilteredContactsChange} onNotice={onNotice} /> + ) : selectedCategory === 'image-key' ? ( + ) : ( )} diff --git a/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts b/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts index b4d14d3..739955a 100644 --- a/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts +++ b/src/renderer/src/features/settings/account-database/useAccountDatabaseController.ts @@ -9,10 +9,6 @@ import { } from './diagnostics' import type { ConnectionCheckState } from './types' -interface AppSettings { - imageAesKey: string -} - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function useAccountDatabaseController({ dbKey, @@ -25,19 +21,19 @@ export function useAccountDatabaseController({ selfInfo: SettingsSelfInfo | null onNotice: (message: string) => void }) { - const [settings, setSettings] = useState(null) + const [hasImageKey, setHasImageKey] = useState(false) const [checkState, setCheckState] = useState({ status: 'idle' }) const [clock, setClock] = useState(() => new Date()) useEffect(() => { let active = true void window.api - .getSettings() + .getImageDecryptionStatus() .then((result) => { - if (active) setSettings(result.settings) + if (active) setHasImageKey(result.configured) }) .catch(() => { - if (active) setSettings(null) + if (active) setHasImageKey(false) }) return () => { active = false @@ -50,7 +46,6 @@ export function useAccountDatabaseController({ return () => window.clearInterval(timer) }, [checkState.checkedAt]) - const hasImageKey = Boolean(settings?.imageAesKey.trim()) const diagnostics = useMemo( () => buildConnectionDiagnostics({ diff --git a/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx b/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx new file mode 100644 index 0000000..c89b7bd --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection.tsx @@ -0,0 +1,114 @@ +import type { ImageDecryptionState } from './types' + +const PHASE_LABELS = { + scanning: '扫描中', + 'candidate-found': '发现候选密钥', + validating: '自动验证图片', + success: '验证成功', + saving: '正在保存', + saved: '已保存' +} as const + +export function AutoDetectImageKeySection({ + state, + disabled, + canSave, + onDetect, + onSave +}: { + state: ImageDecryptionState + disabled: boolean + canSave: boolean + onDetect: () => void + onSave: () => void +}): React.ReactElement { + if (!state.status?.autoDetectSupported) { + return ( +
+ 手动配置图片解密 +

当前平台不支持自动扫描图片密钥,请手动配置。

+
+ ) + } + + const directoryReady = state.status.resources.imageDirectory.state === 'available' + const processReady = state.status.platform !== 'win32' || state.status.wechatRunning + const autoBusy = ['scanning', 'candidate-found', 'validating', 'saving'].includes(state.autoPhase) + const showSuccess = state.autoPhase === 'success' || state.autoPhase === 'saved' + + return ( +
+
+
+ 自动获取图片密钥 +

+ {state.status.platform === 'darwin' + ? '扫描本机微信缓存并通过图片模板验证候选密钥。' + : '扫描微信进程内存并通过本地图片模板验证候选密钥。'} +

+
+ +
+
    +
  • + 微信运行状态:{state.status.wechatRunning ? '正在运行' : '未运行'} +
  • +
  • + 当前账号状态:{state.status.accountIdentified ? '已登录' : '未识别'} +
  • +
  • + 图片目录状态:{directoryReady ? '已找到' : '未找到'} +
  • +
+ {state.autoPhase !== 'idle' && state.autoPhase !== 'failed' ? ( +
    + {(['scanning', 'candidate-found', 'validating', 'success'] as const).map((phase) => ( +
  1. = getPhaseIndex(phase) ? 'active' : ''} + > + {PHASE_LABELS[phase]} +
  2. + ))} +
+ ) : null} + {state.autoProgress ?

{state.autoProgress}

: null} + {state.autoError ?

{state.autoError}

: null} + {showSuccess ? ( +
+ 图片密钥验证成功 + 发现账号:{state.autoAccount || '当前微信账号'} + 图片解析:正常 + +
+ ) : null} +
+ ) +} + +function getPhaseIndex(phase: ImageDecryptionState['autoPhase']): number { + const order: ImageDecryptionState['autoPhase'][] = [ + 'idle', + 'scanning', + 'candidate-found', + 'validating', + 'success', + 'saving', + 'saved' + ] + return order.indexOf(phase) +} diff --git a/src/renderer/src/features/settings/image-decryption/DangerZone.tsx b/src/renderer/src/features/settings/image-decryption/DangerZone.tsx new file mode 100644 index 0000000..92dbaca --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/DangerZone.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' + +export function DangerZone({ + disabled, + onClear +}: { + disabled: boolean + onClear: () => void +}): React.ReactElement { + const [confirming, setConfirming] = useState(false) + return ( + <> +
+

图片密钥管理

+
+ + 清除图片密钥 + 聊天记录和微信原始图片不会被删除。 + + +
+
+ {confirming ? ( +
+
+

确认清除图片解密配置?

+

清除后聊天记录仍然存在,但图片需要重新配置后才能解析。

+
+ + +
+
+
+ ) : null} + + ) +} diff --git a/src/renderer/src/features/settings/image-decryption/ImageDecryptStatus.tsx b/src/renderer/src/features/settings/image-decryption/ImageDecryptStatus.tsx new file mode 100644 index 0000000..5be1f9f --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/ImageDecryptStatus.tsx @@ -0,0 +1,63 @@ +import type { SettingsSelfInfo } from '../model/types' +import type { ImageDecryptionState } from './types' +import { formatImageConfigTime } from './utils' + +export function ImageDecryptStatus({ + state, + selfInfo, + disabled, + onValidate +}: { + state: ImageDecryptionState + selfInfo: SettingsSelfInfo | null + disabled: boolean + onValidate: () => void +}): React.ReactElement { + return ( +
+
+
+
图片密钥
+
+ {state.config?.configured ? '已配置' : '未配置'} +
+
+
+
当前账号
+
{selfInfo?.nickname || '尚未识别'}
+
+
+
wxid
+
+ {selfInfo?.wxid || '—'} +
+
+
+
最近验证
+
{formatImageConfigTime(state.config?.updatedAt)}
+
+
+
图片资源目录
+
+ {state.status?.resourceRoot || '尚未定位'} +
+
+
+
缓存状态
+
+ {state.status?.cacheState === 'normal' ? '正常' : '不可用'} +
+
+
+ {state.config?.configured ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/features/settings/image-decryption/ImageKeyConfiguration.tsx b/src/renderer/src/features/settings/image-decryption/ImageKeyConfiguration.tsx new file mode 100644 index 0000000..b879331 --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/ImageKeyConfiguration.tsx @@ -0,0 +1,47 @@ +import type { ImageDecryptionState } from './types' + +export function ImageKeyConfiguration({ + state, + disabled, + onEdit +}: { + state: ImageDecryptionState + disabled: boolean + onEdit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void +}): React.ReactElement { + return ( +
+ +
+ + +
+

修改后请先选择会话完成图片解析测试,再确认保存。

+
+ ) +} diff --git a/src/renderer/src/features/settings/image-decryption/ImageTestSection.tsx b/src/renderer/src/features/settings/image-decryption/ImageTestSection.tsx new file mode 100644 index 0000000..2b59d5e --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/ImageTestSection.tsx @@ -0,0 +1,63 @@ +import type { ImageDecryptionState } from './types' + +export function ImageTestSection({ + state, + disabled, + canSave, + onSelect, + onTest, + onSave +}: { + state: ImageDecryptionState + disabled: boolean + canSave: boolean + onSelect: (value: string) => void + onTest: () => void + onSave: () => void +}): React.ReactElement { + const result = state.testResult + return ( +
+
+ 图片解析测试 +

选择一条聊天记录测试图片解密能力。

+
+ + +
+ + +
+ {result ? ( +
+ {result.fileFound ? '✓' : '×'} 找到图片文件 + {result.decrypted ? '✓' : '×'} 解密成功 + {result.readable ? '✓' : '×'} 图片可以读取 + {!result.success ?

{result.error}

: null} +
+ ) : state.error ? ( +

{state.error}

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/features/settings/image-decryption/ResourceCheckSection.tsx b/src/renderer/src/features/settings/image-decryption/ResourceCheckSection.tsx new file mode 100644 index 0000000..c467248 --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/ResourceCheckSection.tsx @@ -0,0 +1,38 @@ +import type { + ImageDecryptionStatus, + ImageResourceCheck +} from '../../../../../shared/image-decryption' + +const ITEMS: { key: keyof ImageDecryptionStatus['resources']; label: string }[] = [ + { key: 'imageIndex', label: '图片索引' }, + { key: 'imageDirectory', label: '图片文件目录' }, + { key: 'thumbnail', label: '缩略图资源' }, + { key: 'original', label: '原图资源' }, + { key: 'sticker', label: '表情资源' } +] + +export function ResourceCheckSection({ + status +}: { + status: ImageDecryptionStatus | null +}): React.ReactElement { + return ( +
+ {ITEMS.map(({ key, label }) => { + const item: ImageResourceCheck = status?.resources[key] || { + state: 'unknown', + detail: '正在检查' + } + return ( +
+ + {item.state === 'available' ? '✓' : item.state === 'unavailable' ? '!' : '·'} + + {label} + {item.detail} +
+ ) + })} +
+ ) +} diff --git a/src/renderer/src/features/settings/image-decryption/SecurityInfoSection.tsx b/src/renderer/src/features/settings/image-decryption/SecurityInfoSection.tsx new file mode 100644 index 0000000..05352c3 --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/SecurityInfoSection.tsx @@ -0,0 +1,15 @@ +export function SecurityInfoSection(): React.ReactElement { + return ( +
+ + 系统安全存储图片密钥通过系统安全能力保存。 + + + 账号绑定密钥只用于当前微信账号。 + + + 无损清除清除密钥不会删除微信原始图片。 + +
+ ) +} diff --git a/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts b/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts new file mode 100644 index 0000000..36c6dd9 --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/imageDecryptionReducer.ts @@ -0,0 +1,134 @@ +import type { ImageDecryptionAction, ImageDecryptionState } from './types' + +export const initialImageDecryptionState: ImageDecryptionState = { + phase: 'checking', + config: null, + status: null, + contacts: [], + selectedUserMd5: '', + resourceRoot: '', + xorKey: '0x40', + aesKey: '', + testResult: null, + autoPhase: 'idle', + autoProgress: '', + dirty: false +} + +export function imageDecryptionReducer( + state: ImageDecryptionState, + action: ImageDecryptionAction +): ImageDecryptionState { + switch (action.type) { + case 'LOADED': + return { + ...state, + phase: action.config.configured ? 'configured' : 'not-configured', + config: action.config, + status: action.status, + contacts: action.contacts, + resourceRoot: action.config.resourceRoot, + xorKey: action.config.xorKey || '0x40', + aesKey: action.config.aesKey || '', + error: action.config.success ? undefined : action.config.error, + dirty: false + } + case 'LOAD_ERROR': + return { ...state, phase: 'not-configured', error: action.error } + case 'EDIT': + return { + ...state, + [action.field]: action.value, + phase: state.config?.configured ? 'configured' : 'not-configured', + testResult: null, + error: undefined, + dirty: true + } + case 'SELECT_CHAT': + return { ...state, selectedUserMd5: action.userMd5, testResult: null, error: undefined } + case 'TEST_START': + return { ...state, phase: 'testing', testResult: null, error: undefined } + case 'TEST_DONE': + return { + ...state, + phase: action.result.success ? 'test-success' : 'test-failed', + testResult: action.result, + error: action.result.error, + status: + action.result.success && state.status + ? { + ...state.status, + resources: { + ...state.status.resources, + thumbnail: action.result.isThumbnail + ? { state: 'available', detail: '正常' } + : state.status.resources.thumbnail, + original: action.result.isThumbnail + ? { state: 'unavailable', detail: '本次未找到原图' } + : { state: 'available', detail: '正常' } + } + } + : state.status + } + case 'AUTO_START': + return { + ...state, + phase: 'checking', + autoPhase: 'scanning', + autoProgress: '正在检查微信运行环境', + autoAccount: undefined, + autoError: undefined, + error: undefined + } + case 'AUTO_PROGRESS': + return { ...state, autoProgress: action.message } + case 'AUTO_CANDIDATE': + return { + ...state, + autoPhase: 'candidate-found', + resourceRoot: action.resourceRoot, + xorKey: action.xorKey, + aesKey: action.aesKey, + autoAccount: action.account, + autoProgress: '已发现候选密钥', + dirty: true + } + case 'AUTO_VALIDATING': + return { ...state, autoPhase: 'validating', autoProgress: '正在验证候选密钥的图片解析能力' } + case 'AUTO_DONE': + return { + ...state, + phase: 'test-success', + autoPhase: 'success', + autoProgress: '图片密钥验证成功', + dirty: true + } + case 'AUTO_SAVE_START': + return { ...state, autoPhase: 'saving', autoProgress: '正在安全保存图片密钥' } + case 'AUTO_SAVED': + return { ...state, autoPhase: 'saved', autoProgress: '图片密钥已安全保存' } + case 'AUTO_ERROR': + return { + ...state, + phase: 'test-failed', + autoPhase: 'failed', + autoError: action.error, + autoProgress: '' + } + case 'OPERATION_ERROR': + return { ...state, phase: 'test-failed', error: action.error } + case 'CLEAR_START': + return { ...state, phase: 'clearing', error: undefined } + case 'CLEAR_DONE': + return { + ...initialImageDecryptionState, + phase: 'clear-success', + config: action.config, + status: action.status, + contacts: state.contacts, + resourceRoot: action.config.resourceRoot + } + default: + return state + } +} diff --git a/src/renderer/src/features/settings/image-decryption/types.ts b/src/renderer/src/features/settings/image-decryption/types.ts new file mode 100644 index 0000000..bedb6f2 --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/types.ts @@ -0,0 +1,90 @@ +import type { Contact } from '../../../../../shared/types' +import type { + ImageDecryptionStatus, + ImageDecryptionTestResult, + ImageKeyConfigResult +} from '../../../../../shared/image-decryption' + +export type ImageDecryptionPhase = + | 'idle' + | 'checking' + | 'configured' + | 'not-configured' + | 'testing' + | 'test-success' + | 'test-failed' + | 'clearing' + | 'clear-success' + | 'clear-failed' + +export type ImageKeyAutoDetectPhase = + | 'idle' + | 'scanning' + | 'candidate-found' + | 'validating' + | 'success' + | 'saving' + | 'saved' + | 'failed' + +export interface ImageDecryptionState { + phase: ImageDecryptionPhase + config: ImageKeyConfigResult | null + status: ImageDecryptionStatus | null + contacts: Contact[] + selectedUserMd5: string + resourceRoot: string + xorKey: string + aesKey: string + testResult: ImageDecryptionTestResult | null + autoPhase: ImageKeyAutoDetectPhase + autoProgress: string + autoAccount?: string + autoError?: string + error?: string + dirty: boolean +} + +export type ImageDecryptionAction = + | { + type: 'LOADED' + config: ImageKeyConfigResult + status: ImageDecryptionStatus + contacts: Contact[] + } + | { type: 'LOAD_ERROR'; error: string } + | { type: 'EDIT'; field: 'resourceRoot' | 'xorKey' | 'aesKey'; value: string } + | { type: 'SELECT_CHAT'; userMd5: string } + | { type: 'TEST_START' } + | { type: 'TEST_DONE'; result: ImageDecryptionTestResult } + | { type: 'AUTO_START' } + | { type: 'AUTO_PROGRESS'; message: string } + | { + type: 'AUTO_CANDIDATE' + resourceRoot: string + xorKey: string + aesKey: string + account: string + } + | { type: 'AUTO_VALIDATING' } + | { type: 'AUTO_DONE' } + | { type: 'AUTO_SAVE_START' } + | { type: 'AUTO_SAVED' } + | { type: 'AUTO_ERROR'; error: string } + | { type: 'OPERATION_ERROR'; error: string } + | { type: 'CLEAR_START' } + | { type: 'CLEAR_DONE'; config: ImageKeyConfigResult; status: ImageDecryptionStatus } + +export interface ImageDecryptionController { + state: ImageDecryptionState + pageStatus: 'configured' | 'unconfigured' | 'partial' + busy: boolean + canSave: boolean + edit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void + selectChat: (userMd5: string) => void + test: () => Promise + save: () => Promise + autoDetect: () => Promise + clear: () => Promise + refresh: () => Promise +} diff --git a/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts b/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts new file mode 100644 index 0000000..6582bc4 --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/useImageDecryptionController.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useMemo, useReducer } from 'react' +import type { SettingsSelfInfo } from '../model/types' +import { imageDecryptionReducer, initialImageDecryptionState } from './imageDecryptionReducer' +import type { ImageDecryptionController } from './types' +import { normalizeAutoXorKey, sanitizeImageError } from './utils' + +export function useImageDecryptionController({ + selfInfo, + onNotice +}: { + selfInfo: SettingsSelfInfo | null + onNotice: (message: string) => void +}): ImageDecryptionController { + const [state, dispatch] = useReducer(imageDecryptionReducer, initialImageDecryptionState) + + const refresh = useCallback(async (): Promise => { + try { + const [config, status, contacts] = await Promise.all([ + window.api.getImageKeyConfig(), + window.api.getImageDecryptionStatus(), + window.api.getContacts() + ]) + dispatch({ type: 'LOADED', config, status, contacts }) + } catch { + dispatch({ type: 'LOAD_ERROR', error: '无法读取图片解密状态' }) + } + }, []) + + useEffect(() => { + void refresh() + return window.api.onImageKeyStatus(({ message }) => { + dispatch({ type: 'AUTO_PROGRESS', message }) + }) + }, [refresh]) + + const edit = useCallback((field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string): void => { + dispatch({ type: 'EDIT', field, value }) + }, []) + + const selectChat = useCallback((userMd5: string): void => { + dispatch({ type: 'SELECT_CHAT', userMd5 }) + }, []) + + const test = useCallback(async (): Promise => { + dispatch({ type: 'TEST_START' }) + const result = await window.api.testImageDecryption({ + userMd5: state.selectedUserMd5, + resourceRoot: state.resourceRoot, + xorKey: state.xorKey, + aesKey: state.aesKey + }) + dispatch({ + type: 'TEST_DONE', + result: result.success ? result : { ...result, error: sanitizeImageError(result.error) } + }) + }, [state.aesKey, state.resourceRoot, state.selectedUserMd5, state.xorKey]) + + const save = useCallback(async (): Promise => { + if (!state.testResult?.success && state.autoPhase !== 'success') return + if (state.autoPhase === 'success') dispatch({ type: 'AUTO_SAVE_START' }) + const result = await window.api.saveImageKeyConfig({ + resourceRoot: state.resourceRoot, + xorKey: state.xorKey, + aesKey: state.aesKey + }) + if (!result.success) { + dispatch({ type: 'OPERATION_ERROR', error: sanitizeImageError(result.error) }) + return + } + await refresh() + if (state.autoPhase === 'success') dispatch({ type: 'AUTO_SAVED' }) + onNotice('图片解密配置已安全保存') + }, [ + onNotice, + refresh, + state.aesKey, + state.autoPhase, + state.resourceRoot, + state.testResult, + state.xorKey + ]) + + const autoDetect = useCallback(async (): Promise => { + dispatch({ type: 'AUTO_START' }) + const result = await window.api.autoGetImageKey({ save: false }) + if (!result.success || !result.aesKey || !result.verified) { + dispatch({ + type: 'AUTO_ERROR', + error: result.success + ? '获取到候选密钥,但未通过图片验证' + : sanitizeImageError(result.error) + }) + return + } + dispatch({ + type: 'AUTO_CANDIDATE', + resourceRoot: state.resourceRoot || selfInfo?.accountRoot || '', + xorKey: normalizeAutoXorKey(result.xorKey, result.imageXorKey), + aesKey: result.aesKey, + account: selfInfo?.nickname || selfInfo?.wxid || '当前微信账号' + }) + dispatch({ type: 'AUTO_VALIDATING' }) + // Windows 内存扫描只会返回通过模板图片头验证的候选密钥。 + dispatch({ type: 'AUTO_DONE' }) + onNotice('已获取有效图片密钥,请确认保存') + }, [onNotice, selfInfo?.accountRoot, selfInfo?.nickname, selfInfo?.wxid, state.resourceRoot]) + + const clear = useCallback(async (): Promise => { + dispatch({ type: 'CLEAR_START' }) + const result = await window.api.clearImageKeyConfig() + if (!result.success) { + dispatch({ type: 'OPERATION_ERROR', error: '图片解密配置清除失败' }) + return + } + const [config, status] = await Promise.all([ + window.api.getImageKeyConfig(), + window.api.getImageDecryptionStatus() + ]) + dispatch({ type: 'CLEAR_DONE', config, status }) + onNotice('图片密钥已清除,微信原始数据未受影响') + }, [onNotice]) + + const busy = ['checking', 'testing', 'clearing'].includes(state.phase) + const canSave = Boolean( + ((state.testResult?.success && state.dirty) || state.autoPhase === 'success') && + state.status?.encryptionAvailable + ) + const pageStatus = useMemo(() => { + if (!state.config?.configured) return 'unconfigured' + if (!state.config.saved || !state.status?.encryptionAvailable) return 'partial' + if (state.status.resources.imageDirectory.state === 'unavailable') return 'partial' + return 'configured' + }, [state.config, state.status]) + + return { + state, + pageStatus, + busy, + canSave, + edit, + selectChat, + test, + save, + autoDetect, + clear, + refresh + } +} diff --git a/src/renderer/src/features/settings/image-decryption/utils.ts b/src/renderer/src/features/settings/image-decryption/utils.ts new file mode 100644 index 0000000..21e5785 --- /dev/null +++ b/src/renderer/src/features/settings/image-decryption/utils.ts @@ -0,0 +1,27 @@ +export function formatImageConfigTime(value?: number): string { + if (!value) return '尚未验证' + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }).format(value) +} + +export function sanitizeImageError(error?: string): string { + const value = String(error || '').toLowerCase() + if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配' + if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在' + if (value.includes('账号')) return '当前账号不匹配' + if (value.includes('目录')) return '图片资源目录不可用' + return error ? '无法解析媒体文件' : '图片解析测试未通过' +} + +export function normalizeAutoXorKey(value?: number, formatted?: string): string { + if (formatted) return formatted + return `0x${Number(value ?? 0x40) + .toString(16) + .toUpperCase() + .padStart(2, '0')}` +} diff --git a/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx b/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx new file mode 100644 index 0000000..2e7e3f9 --- /dev/null +++ b/src/renderer/src/features/settings/pages/ImageDecryptionPage.tsx @@ -0,0 +1,105 @@ +import type { SettingsSelfInfo } from '../model/types' +import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection' +import { DangerZone } from '../image-decryption/DangerZone' +import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus' +import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration' +import { ImageTestSection } from '../image-decryption/ImageTestSection' +import { ResourceCheckSection } from '../image-decryption/ResourceCheckSection' +import { SecurityInfoSection } from '../image-decryption/SecurityInfoSection' +import { useImageDecryptionController } from '../image-decryption/useImageDecryptionController' + +const STATUS_LABELS = { + configured: '已配置', + unconfigured: '未配置', + partial: '部分能力不可用' +} + +export function ImageDecryptionPage({ + selfInfo, + onNotice +}: { + selfInfo: SettingsSelfInfo | null + onNotice: (message: string) => void +}): React.ReactElement { + const controller = useImageDecryptionController({ selfInfo, onNotice }) + const revalidate = (): void => { + if (controller.state.selectedUserMd5) { + void controller.test() + return + } + document.getElementById('image-test-chat')?.scrollIntoView({ behavior: 'smooth' }) + window.setTimeout(() => document.getElementById('image-test-chat')?.focus(), 250) + onNotice('请先选择一条包含图片的聊天记录') + } + + return ( +
+
+
+

图片解密

+

管理微信图片、表情和媒体资源解析能力

+
+ + {STATUS_LABELS[controller.pageStatus]} + +
+
+
+
+ + + +
+ 图片仅在本机解析 +

WechatExplorer 不会上传您的微信图片。所有图片解析和缓存处理均在本地完成。

+
+
+ +

图片解密状态

+ + +

资源检测

+ + +

图片密钥管理

+ + +

自动获取图片密钥

+ void controller.autoDetect()} + onSave={() => void controller.save()} + /> + +

图片解析测试

+ void controller.test()} + onSave={() => void controller.save()} + /> + +

安全说明

+ + void controller.clear()} + /> +
+
+
+ ) +} diff --git a/src/shared/image-decryption.ts b/src/shared/image-decryption.ts new file mode 100644 index 0000000..e72a734 --- /dev/null +++ b/src/shared/image-decryption.ts @@ -0,0 +1,71 @@ +export type ImageKeySource = 'secure-storage' | 'legacy-settings' | 'environment' | 'none' +export type ImageResourceState = 'available' | 'unavailable' | 'unknown' + +export interface ImageKeyConfigResult { + success: boolean + configured: boolean + saved: boolean + encryptionAvailable: boolean + source: ImageKeySource + accountId?: string + resourceRoot: string + xorKey?: string + aesKey?: string + updatedAt?: number + error?: string +} + +export interface ImageResourceCheck { + state: ImageResourceState + detail: string +} + +export interface ImageDecryptionStatus { + configured: boolean + saved: boolean + encryptionAvailable: boolean + source: ImageKeySource + accountId?: string + resourceRoot: string + updatedAt?: number + platform: NodeJS.Platform + autoDetectSupported: boolean + wechatRunning: boolean + accountIdentified: boolean + cacheState: 'normal' | 'unavailable' + resources: { + imageIndex: ImageResourceCheck + imageDirectory: ImageResourceCheck + thumbnail: ImageResourceCheck + original: ImageResourceCheck + sticker: ImageResourceCheck + video: ImageResourceCheck + } +} + +export interface SaveImageKeyRequest { + resourceRoot: string + xorKey: string + aesKey: string +} + +export interface TestImageDecryptionRequest extends SaveImageKeyRequest { + userMd5: string +} + +export interface ImageDecryptionTestResult { + success: boolean + code?: + | 'NOT_CONFIGURED' + | 'NO_CONVERSATION' + | 'NO_IMAGE_MESSAGE' + | 'FILE_NOT_FOUND' + | 'DECRYPT_FAILED' + | 'ACCOUNT_MISMATCH' + | 'UNKNOWN' + error?: string + fileFound: boolean + decrypted: boolean + readable: boolean + isThumbnail?: boolean +}