实现图片解密设置页

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
parent f1ceef0e5e
commit 7b54b611d3
24 changed files with 1541 additions and 42 deletions
+99
View File
@@ -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<string, StoredImageKeyEntry>
}
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<StoredImageKeyEntry, 'updatedAt'>
): { 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: '图片密钥保存失败' }
}
}
}
+69 -28
View File
@@ -28,6 +28,7 @@ import {
import type { GroupReportExportRequest } from '../shared/group-report' import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history' import type { SaveGeneratedReportRequest } from '../shared/report-history'
import { DatabaseKeyStore } from './database-key-store' import { DatabaseKeyStore } from './database-key-store'
import { ImageKeyConfigService } from './services/image-key-config-service'
import { KeyServiceMac } from './key-service-mac' import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win' import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service' import * as chat from './services/chat-service'
@@ -35,6 +36,11 @@ import { apiServer } from './http-server'
import { skillResourceService } from './services/skill-resource-service' import { skillResourceService } from './services/skill-resource-service'
import { testLocalApiRequest } from './services/local-api-test-service' import { testLocalApiRequest } from './services/local-api-test-service'
import { isWindowsWechatRunning } from './services/wechat-process-status' 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 { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
import { import {
getBootstrapCache, getBootstrapCache,
@@ -56,6 +62,7 @@ let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null let stickerService: StickerService | null = null
const databaseKeyStore = new DatabaseKeyStore() const databaseKeyStore = new DatabaseKeyStore()
const imageKeyConfigService = new ImageKeyConfigService()
const keyServiceMac = new KeyServiceMac() const keyServiceMac = new KeyServiceMac()
const keyServiceWin = new KeyServiceWin() const keyServiceWin = new KeyServiceWin()
let tray: Tray | null = null let tray: Tray | null = null
@@ -69,24 +76,11 @@ const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
const TRAY_MODE = const TRAY_MODE =
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1' 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 } { function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
const settings = loadSettings() const config = imageKeyConfigService.getConfig()
return { return {
xorKey: settings.imageXorKey || import.meta.env.VITE_IMAGE_XOR_KEY || '0x40', xorKey: config.xorKey || '0x40',
aesKey: settings.imageAesKey || import.meta.env.VITE_IMAGE_AES_KEY || '' 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 settings = loadSettings()
const self = chat.getSelfAccountInfo() const self = chat.getSelfAccountInfo()
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
@@ -261,22 +255,57 @@ app.whenReady().then(async () => {
: await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid) : await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid)
if (!result.success || !result.aesKey) return result if (!result.success || !result.aesKey) return result
if (process.platform === 'win32') {
onStatus('发现候选密钥,图片模板验证通过')
}
const imageXorKey = normalizeImageXorKey(result.xorKey) const imageXorKey = `0x${Number(result.xorKey ?? 0x40)
const nextSettings = saveSettings({ .toString(16)
...settings, .toUpperCase()
imageXorKey, .padStart(2, '0')}`
imageAesKey: result.aesKey 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 { return {
...result, ...result,
success: saved.success,
error: saved.success ? undefined : saved.error,
verified,
imageXorKey, imageXorKey,
imageAesKey: result.aesKey, 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', () => { ipcMain.handle('db:getBootstrapCache', () => {
if (!chat.isReady()) return null if (!chat.isReady()) return null
return getBootstrapCache(chat.getCurrentAccountRoot()) return getBootstrapCache(chat.getCurrentAccountRoot())
@@ -481,17 +510,29 @@ app.whenReady().then(async () => {
// -------- Settings & API service -------- // -------- Settings & API service --------
ipcMain.handle('settings:get', () => ({ ipcMain.handle('settings:get', () => ({
settings: loadSettings(), settings: imageKeyConfigService.getLegacySettingsView(),
settingsPath: getSettingsPath() settingsPath: getSettingsPath()
})) }))
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => { ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
const before = loadSettings() const before = loadSettings()
const merged = saveSettings({ ...before, ...patch }) const current = imageKeyConfigService.getConfig()
if (before.imageXorKey !== merged.imageXorKey || before.imageAesKey !== merged.imageAesKey) { 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 imageDecryptService = null
} else {
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
}
return {
settings: imageKeyConfigService.getLegacySettingsView(),
settingsPath: getSettingsPath()
} }
return { settings: merged, settingsPath: getSettingsPath() }
}) })
ipcMain.handle('settings:getSelf', () => { ipcMain.handle('settings:getSelf', () => {
@@ -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<ImageDecryptionStatus> {
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<ImageDecryptionTestResult['code']>,
error: string
): ImageDecryptionTestResult {
return { success: false, code, error, fileFound: false, decrypted: false, readable: false }
}
@@ -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 }
}
+7 -3
View File
@@ -11,6 +11,7 @@ export interface AppSettings {
imageKeyRoot: string imageKeyRoot: string
imageXorKey: string imageXorKey: string
imageAesKey: string imageAesKey: string
imageKeyFallbackDisabled: boolean
} }
function getDefaultDbRoot(): string { function getDefaultDbRoot(): string {
@@ -21,7 +22,9 @@ function getDefaultDbRoot(): string {
function getDefaultDbRootCandidates(home: string): string[] { function getDefaultDbRootCandidates(home: string): string[] {
if (process.platform !== 'win32') { 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 = [ const candidates = [
@@ -113,8 +116,9 @@ const DEFAULT_SETTINGS: AppSettings = {
apiHost: '127.0.0.1', apiHost: '127.0.0.1',
apiPort: 6131, apiPort: 6131,
imageKeyRoot: defaultDbRoot, imageKeyRoot: defaultDbRoot,
imageXorKey: process.env.VITE_IMAGE_XOR_KEY || '', imageXorKey: '',
imageAesKey: process.env.VITE_IMAGE_AES_KEY || '' imageAesKey: '',
imageKeyFallbackDisabled: false
} }
const SETTINGS_FILE = path.join( const SETTINGS_FILE = path.join(
@@ -21,3 +21,21 @@ export async function isWindowsWechatRunning(): Promise<boolean> {
} }
return false return false
} }
export async function isWechatRunning(): Promise<boolean> {
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
}
+15 -1
View File
@@ -13,6 +13,13 @@ import type {
DatabaseKeyStorageResult, DatabaseKeyStorageResult,
DatabaseKeyValidationResult DatabaseKeyValidationResult
} from '../shared/database-key' } from '../shared/database-key'
import type {
ImageDecryptionStatus,
ImageDecryptionTestResult,
ImageKeyConfigResult,
SaveImageKeyRequest,
TestImageDecryptionRequest
} from '../shared/image-decryption'
export type ParsedContent = export type ParsedContent =
| { type: 'text'; content: string } | { type: 'text'; content: string }
@@ -133,7 +140,7 @@ declare global {
saved?: boolean saved?: boolean
warning?: string warning?: string
}> }>
autoGetImageKey: () => Promise<{ autoGetImageKey: (options?: { save?: boolean }) => Promise<{
success: boolean success: boolean
xorKey?: number xorKey?: number
aesKey?: string aesKey?: string
@@ -151,6 +158,13 @@ declare global {
imageAesKey: string imageAesKey: string
} }
}> }>
getImageKeyConfig: () => Promise<ImageKeyConfigResult>
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
testImageDecryption: (
request: TestImageDecryptionRequest
) => Promise<ImageDecryptionTestResult>
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult> saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult>
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
+7 -1
View File
@@ -47,7 +47,13 @@ const api = {
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'), getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'), readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options), 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'), pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key), saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'), clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
+11
View File
@@ -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-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-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; } .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); } } @keyframes settings-spin { to { transform:rotate(360deg); } }
@media (max-width:900px) { @media (max-width:900px) {
.settings-sidebar { width:248px; flex-basis:248px; } .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-facts { grid-column:1/-1; grid-template-columns:repeat(2,minmax(0,1fr)); }
.settings-account-actions { grid-row:1; grid-column:2; } .settings-account-actions { grid-row:1; grid-column:2; }
.database-key-security-info { grid-template-columns:1fr; } .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) { @media (max-width:700px) {
.settings-account-overview { grid-template-columns:minmax(0,1fr); } .settings-account-overview { grid-template-columns:minmax(0,1fr); }
@@ -3661,6 +3671,7 @@ body {
.settings-account-root { grid-column:1; } .settings-account-root { grid-column:1; }
.database-key-status-card dl,.database-key-diagnostics dl { grid-template-columns:1fr; } .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; } .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-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); } .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); }
@@ -4,6 +4,7 @@ import { SETTINGS_CATEGORY_LABELS } from './model/settingsNavigation'
import type { SettingsCategoryId, SettingsSelfInfo } from './model/types' import type { SettingsCategoryId, SettingsSelfInfo } from './model/types'
import { AccountDatabasePage } from './pages/AccountDatabasePage' import { AccountDatabasePage } from './pages/AccountDatabasePage'
import { DatabaseKeyPage } from './pages/DatabaseKeyPage' import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
import type { Contact } from '../../../../shared/types' import type { Contact } from '../../../../shared/types'
export function SettingsWorkspace({ export function SettingsWorkspace({
@@ -61,6 +62,8 @@ export function SettingsWorkspace({
onFilteredContactsChange={onFilteredContactsChange} onFilteredContactsChange={onFilteredContactsChange}
onNotice={onNotice} onNotice={onNotice}
/> />
) : selectedCategory === 'image-key' ? (
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
) : ( ) : (
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} /> <SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
)} )}
@@ -9,10 +9,6 @@ import {
} from './diagnostics' } from './diagnostics'
import type { ConnectionCheckState } from './types' import type { ConnectionCheckState } from './types'
interface AppSettings {
imageAesKey: string
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function useAccountDatabaseController({ export function useAccountDatabaseController({
dbKey, dbKey,
@@ -25,19 +21,19 @@ export function useAccountDatabaseController({
selfInfo: SettingsSelfInfo | null selfInfo: SettingsSelfInfo | null
onNotice: (message: string) => void onNotice: (message: string) => void
}) { }) {
const [settings, setSettings] = useState<AppSettings | null>(null) const [hasImageKey, setHasImageKey] = useState(false)
const [checkState, setCheckState] = useState<ConnectionCheckState>({ status: 'idle' }) const [checkState, setCheckState] = useState<ConnectionCheckState>({ status: 'idle' })
const [clock, setClock] = useState(() => new Date()) const [clock, setClock] = useState(() => new Date())
useEffect(() => { useEffect(() => {
let active = true let active = true
void window.api void window.api
.getSettings() .getImageDecryptionStatus()
.then((result) => { .then((result) => {
if (active) setSettings(result.settings) if (active) setHasImageKey(result.configured)
}) })
.catch(() => { .catch(() => {
if (active) setSettings(null) if (active) setHasImageKey(false)
}) })
return () => { return () => {
active = false active = false
@@ -50,7 +46,6 @@ export function useAccountDatabaseController({
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [checkState.checkedAt]) }, [checkState.checkedAt])
const hasImageKey = Boolean(settings?.imageAesKey.trim())
const diagnostics = useMemo( const diagnostics = useMemo(
() => () =>
buildConnectionDiagnostics({ buildConnectionDiagnostics({
@@ -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 (
<section className="settings-card image-auto-unavailable">
<strong></strong>
<p></p>
</section>
)
}
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 (
<section className="settings-card image-auto-detect">
<div className="image-auto-heading">
<div>
<strong></strong>
<p>
{state.status.platform === 'darwin'
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
</p>
</div>
<button
className="database-key-secondary"
disabled={
disabled ||
autoBusy ||
!processReady ||
!state.status.accountIdentified ||
!directoryReady
}
onClick={onDetect}
>
{state.autoPhase === 'scanning' ? '扫描中…' : '开始自动获取'}
</button>
</div>
<ul>
<li className={state.status.wechatRunning ? 'ok' : ''}>
{state.status.wechatRunning ? '正在运行' : '未运行'}
</li>
<li className={state.status.accountIdentified ? 'ok' : ''}>
{state.status.accountIdentified ? '已登录' : '未识别'}
</li>
<li className={directoryReady ? 'ok' : ''}>
{directoryReady ? '已找到' : '未找到'}
</li>
</ul>
{state.autoPhase !== 'idle' && state.autoPhase !== 'failed' ? (
<ol className="image-auto-phases">
{(['scanning', 'candidate-found', 'validating', 'success'] as const).map((phase) => (
<li
key={phase}
className={getPhaseIndex(state.autoPhase) >= getPhaseIndex(phase) ? 'active' : ''}
>
{PHASE_LABELS[phase]}
</li>
))}
</ol>
) : null}
{state.autoProgress ? <p className="image-auto-progress">{state.autoProgress}</p> : null}
{state.autoError ? <p className="image-auto-error">{state.autoError}</p> : null}
{showSuccess ? (
<div className="image-auto-success">
<strong></strong>
<span>{state.autoAccount || '当前微信账号'}</span>
<span></span>
<button className="database-key-primary" disabled={!canSave} onClick={onSave}>
{state.autoPhase === 'saved' ? '已保存' : '保存图片密钥'}
</button>
</div>
) : null}
</section>
)
}
function getPhaseIndex(phase: ImageDecryptionState['autoPhase']): number {
const order: ImageDecryptionState['autoPhase'][] = [
'idle',
'scanning',
'candidate-found',
'validating',
'success',
'saving',
'saved'
]
return order.indexOf(phase)
}
@@ -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 (
<>
<section className="database-key-danger image-key-danger">
<h2></h2>
<div>
<span>
<strong></strong>
<small></small>
</span>
<button disabled={disabled} onClick={() => setConfirming(true)}>
</button>
</div>
</section>
{confirming ? (
<div className="database-key-confirm-backdrop" role="presentation">
<div className="database-key-confirm" role="dialog" aria-modal="true">
<h2></h2>
<p></p>
<div>
<button onClick={() => setConfirming(false)}></button>
<button
className="danger"
onClick={() => {
setConfirming(false)
onClear()
}}
>
</button>
</div>
</div>
</div>
) : null}
</>
)
}
@@ -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 (
<section className="settings-card image-decrypt-status">
<dl>
<div>
<dt></dt>
<dd className={state.config?.configured ? 'image-state-success' : 'image-state-muted'}>
{state.config?.configured ? '已配置' : '未配置'}
</dd>
</div>
<div>
<dt></dt>
<dd>{selfInfo?.nickname || '尚未识别'}</dd>
</div>
<div>
<dt>wxid</dt>
<dd className="image-decrypt-mono" title={selfInfo?.wxid}>
{selfInfo?.wxid || '—'}
</dd>
</div>
<div>
<dt></dt>
<dd>{formatImageConfigTime(state.config?.updatedAt)}</dd>
</div>
<div className="image-decrypt-wide">
<dt></dt>
<dd className="image-decrypt-mono" title={state.status?.resourceRoot}>
{state.status?.resourceRoot || '尚未定位'}
</dd>
</div>
<div>
<dt></dt>
<dd
className={
state.status?.cacheState === 'normal' ? 'image-state-success' : 'image-state-error'
}
>
{state.status?.cacheState === 'normal' ? '正常' : '不可用'}
</dd>
</div>
</dl>
{state.config?.configured ? (
<button className="database-key-secondary" disabled={disabled} onClick={onValidate}>
</button>
) : null}
</section>
)
}
@@ -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 (
<section className="settings-card image-key-editor">
<label>
<span></span>
<input
value={state.resourceRoot}
disabled={disabled}
title={state.resourceRoot}
onChange={(event) => onEdit('resourceRoot', event.target.value)}
/>
</label>
<div className="image-key-grid">
<label>
<span>XOR Key</span>
<input
value={state.xorKey}
disabled={disabled}
onChange={(event) => onEdit('xorKey', event.target.value)}
/>
</label>
<label>
<span>AES Key</span>
<input
type="password"
value={state.aesKey}
disabled={disabled}
autoComplete="off"
placeholder="输入 16 位图片密钥"
onChange={(event) => onEdit('aesKey', event.target.value)}
/>
</label>
</div>
<p></p>
</section>
)
}
@@ -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 (
<section className="settings-card image-test-section">
<div>
<strong></strong>
<p></p>
</div>
<label htmlFor="image-test-chat"></label>
<select
id="image-test-chat"
value={state.selectedUserMd5}
disabled={disabled}
onChange={(event) => onSelect(event.target.value)}
>
<option value=""></option>
{state.contacts.map((contact) => (
<option key={contact.md5} value={contact.md5}>
{contact.m_nsNickName || contact.m_nsUsrName}
</option>
))}
</select>
<div className="image-test-actions">
<button
className="database-key-primary"
disabled={disabled || !state.selectedUserMd5}
onClick={onTest}
>
{state.phase === 'testing' ? '正在测试…' : '测试图片解析'}
</button>
<button className="database-key-secondary" disabled={!canSave} onClick={onSave}>
</button>
</div>
{result ? (
<div className={`image-test-result ${result.success ? 'success' : 'error'}`}>
<span>{result.fileFound ? '✓' : '×'} </span>
<span>{result.decrypted ? '✓' : '×'} </span>
<span>{result.readable ? '✓' : '×'} </span>
{!result.success ? <p>{result.error}</p> : null}
</div>
) : state.error ? (
<p className="image-inline-error">{state.error}</p>
) : null}
</section>
)
}
@@ -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 (
<section className="settings-card image-resource-checks">
{ITEMS.map(({ key, label }) => {
const item: ImageResourceCheck = status?.resources[key] || {
state: 'unknown',
detail: '正在检查'
}
return (
<div key={key}>
<span className={`image-resource-icon ${item.state}`}>
{item.state === 'available' ? '✓' : item.state === 'unavailable' ? '!' : '·'}
</span>
<strong>{label}</strong>
<small>{item.detail}</small>
</div>
)
})}
</section>
)
}
@@ -0,0 +1,15 @@
export function SecurityInfoSection(): React.ReactElement {
return (
<div className="database-key-security-info">
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
)
}
@@ -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
}
}
@@ -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<void>
save: () => Promise<void>
autoDetect: () => Promise<void>
clear: () => Promise<void>
refresh: () => Promise<void>
}
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<ImageDecryptionController['pageStatus']>(() => {
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
}
}
@@ -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')}`
}
@@ -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 (
<div className="settings-page image-decryption-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p></p>
</div>
<span className={`settings-status-badge image-decrypt-badge ${controller.pageStatus}`}>
{STATUS_LABELS[controller.pageStatus]}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content image-decryption-content">
<section className="settings-privacy-notice">
<svg viewBox="0 0 24 24" aria-hidden>
<path d="M12 3 5.5 5.7v5.2c0 4.3 2.7 8.2 6.5 10.1 3.8-1.9 6.5-5.8 6.5-10.1V5.7L12 3Z" />
</svg>
<div>
<strong></strong>
<p>WechatExplorer </p>
</div>
</section>
<h2 className="settings-section-heading"></h2>
<ImageDecryptStatus
state={controller.state}
selfInfo={selfInfo}
disabled={controller.busy}
onValidate={revalidate}
/>
<h2 className="settings-section-heading"></h2>
<ResourceCheckSection status={controller.state.status} />
<h2 className="settings-section-heading"></h2>
<ImageKeyConfiguration
state={controller.state}
disabled={controller.busy}
onEdit={controller.edit}
/>
<h2 className="settings-section-heading"></h2>
<AutoDetectImageKeySection
state={controller.state}
disabled={controller.busy}
canSave={controller.canSave}
onDetect={() => void controller.autoDetect()}
onSave={() => void controller.save()}
/>
<h2 className="settings-section-heading"></h2>
<ImageTestSection
state={controller.state}
disabled={controller.busy}
canSave={controller.canSave}
onSelect={controller.selectChat}
onTest={() => void controller.test()}
onSave={() => void controller.save()}
/>
<h2 className="settings-section-heading"></h2>
<SecurityInfoSection />
<DangerZone
disabled={controller.busy || !controller.state.config?.configured}
onClear={() => void controller.clear()}
/>
</div>
</div>
</div>
)
}
+71
View File
@@ -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
}