From f1ceef0e5e8b5071ad79c1767e25f0982c4cea53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= Date: Mon, 13 Jul 2026 15:18:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E8=AE=BE=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/database-key-store.ts | 71 ++++-- src/main/index.ts | 26 ++- src/main/services/chat-service.ts | 112 ++++++--- src/main/services/wechat-process-status.ts | 23 ++ src/preload/index.d.ts | 27 ++- src/preload/index.ts | 4 +- src/renderer/src/App.tsx | 22 +- src/renderer/src/assets/main.css | 39 ++++ .../features/settings/SettingsWorkspace.tsx | 24 ++ .../database-key/DatabaseKeyAutoDetect.tsx | 76 ++++++ .../database-key/DatabaseKeyDangerZone.tsx | 74 ++++++ .../database-key/DatabaseKeyDiagnostics.tsx | 65 ++++++ .../database-key/DatabaseKeyEditor.tsx | 93 ++++++++ .../database-key/DatabaseKeyStatus.tsx | 58 +++++ .../database-key/DatabaseKeyValidation.tsx | 36 +++ .../database-key/databaseKeyReducer.ts | 90 +++++++ .../features/settings/database-key/types.ts | 64 +++++ .../database-key/useDatabaseKeyController.ts | 220 ++++++++++++++++++ .../features/settings/database-key/utils.ts | 43 ++++ .../settings/pages/DatabaseKeyPage.tsx | 140 +++++++++++ src/shared/database-key.ts | 34 +++ 21 files changed, 1264 insertions(+), 77 deletions(-) create mode 100644 src/main/services/wechat-process-status.ts create mode 100644 src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect.tsx create mode 100644 src/renderer/src/features/settings/database-key/DatabaseKeyDangerZone.tsx create mode 100644 src/renderer/src/features/settings/database-key/DatabaseKeyDiagnostics.tsx create mode 100644 src/renderer/src/features/settings/database-key/DatabaseKeyEditor.tsx create mode 100644 src/renderer/src/features/settings/database-key/DatabaseKeyStatus.tsx create mode 100644 src/renderer/src/features/settings/database-key/DatabaseKeyValidation.tsx create mode 100644 src/renderer/src/features/settings/database-key/databaseKeyReducer.ts create mode 100644 src/renderer/src/features/settings/database-key/types.ts create mode 100644 src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts create mode 100644 src/renderer/src/features/settings/database-key/utils.ts create mode 100644 src/renderer/src/features/settings/pages/DatabaseKeyPage.tsx create mode 100644 src/shared/database-key.ts diff --git a/src/main/database-key-store.ts b/src/main/database-key-store.ts index 904d973..4ed094a 100644 --- a/src/main/database-key-store.ts +++ b/src/main/database-key-store.ts @@ -1,12 +1,7 @@ import { app, safeStorage } from 'electron' import fs from 'fs-extra' import path from 'path' - -export interface StoredKeyResult { - success: boolean - key?: string - error?: string -} +import type { DatabaseKeyStorageResult } from '../shared/database-key' const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '') @@ -18,37 +13,67 @@ export class DatabaseKeyStore { return path.join(app.getPath('userData'), 'wechat-db-key.bin') } - async load(): Promise { - try { - if (!(await fs.pathExists(this.filePath))) return { success: true } - if (!safeStorage.isEncryptionAvailable()) { - return { success: false, error: '系统安全存储不可用' } - } - const encrypted = await fs.readFile(this.filePath) - const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted)) - if (!isValidDatabaseKey(key)) return { success: false, error: '已保存的密钥格式无效' } - return { success: true, key } - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) } + async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> { + return { + saved: await fs.pathExists(this.filePath), + encryptionAvailable: safeStorage.isEncryptionAvailable() } } - async save(rawKey: string): Promise { + async load(): Promise { + try { + const status = await this.getStatus() + if (!status.saved) return { success: true, ...status } + if (!status.encryptionAvailable) { + return { success: false, error: '系统安全存储不可用', ...status } + } + const encrypted = await fs.readFile(this.filePath) + const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted)) + if (!isValidDatabaseKey(key)) { + return { success: false, error: '已保存的密钥格式无效', ...status } + } + return { success: true, key, ...status } + } catch (error) { + const status = await this.getStatus() + return { + success: false, + error: error instanceof Error ? error.message : String(error), + ...status + } + } + } + + async save(rawKey: string): Promise { const key = normalizeDatabaseKey(rawKey) if (!isValidDatabaseKey(key)) { - return { success: false, error: '密钥必须是 64 位十六进制字符' } + return { + success: false, + error: '密钥必须是 64 位十六进制字符', + saved: await fs.pathExists(this.filePath), + encryptionAvailable: safeStorage.isEncryptionAvailable() + } } if (!safeStorage.isEncryptionAvailable()) { - return { success: false, error: '系统安全存储不可用' } + return { + success: false, + error: '系统安全存储不可用', + saved: await fs.pathExists(this.filePath), + encryptionAvailable: false + } } try { await fs.ensureDir(path.dirname(this.filePath)) await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 }) await fs.chmod(this.filePath, 0o600) - return { success: true, key } + return { success: true, key, saved: true, encryptionAvailable: true } } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) } + return { + success: false, + error: error instanceof Error ? error.message : String(error), + saved: await fs.pathExists(this.filePath), + encryptionAvailable: true + } } } diff --git a/src/main/index.ts b/src/main/index.ts index ca3e88e..740ad80 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,6 +34,7 @@ import * as chat from './services/chat-service' 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 { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store' import { getBootstrapCache, @@ -194,6 +195,27 @@ app.whenReady().then(async () => { ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load()) + ipcMain.handle('key:getEnvironment', async () => { + const storage = await databaseKeyStore.getStatus() + const self = chat.getSelfAccountInfo() + return { + platform: process.platform, + autoDetectSupported: process.platform === 'win32', + wechatRunning: await isWindowsWechatRunning(), + accountIdentified: Boolean(self?.wxid), + dbConnected: chat.isReady(), + encryptionAvailable: storage.encryptionAvailable + } + }) + + ipcMain.handle('key:readClipboardDbKey', () => { + try { + return { success: true, value: clipboard.readText().trim() } + } catch { + return { success: false, error: '无法读取剪贴板' } + } + }) + ipcMain.handle('key:pasteAndSaveDbKey', async () => { const clipboardKey = clipboard.readText().trim() return databaseKeyStore.save(clipboardKey) @@ -205,7 +227,7 @@ app.whenReady().then(async () => { ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear()) - ipcMain.handle('key:autoGetDbKey', async (event) => { + ipcMain.handle('key:autoGetDbKey', async (event, options?: { save?: boolean }) => { const onStatus = (message: string): void => { if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message }) } @@ -215,6 +237,8 @@ app.whenReady().then(async () => { : await keyServiceMac.autoGetDbKey(onStatus) if (!result.success || !result.key) return result + if (options?.save === false) return result + const saved = await databaseKeyStore.save(result.key) return { ...result, diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index f9f55e7..06e264d 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -4,6 +4,10 @@ import { parseMessageContent, parseStickerMessageFromRow } from '../message-parser' +import type { + DatabaseKeyValidationCode, + DatabaseKeyValidationResult +} from '../../shared/database-key' export function getCurrentKey(): string { if (!dbRef) return '' @@ -239,11 +243,7 @@ export function listMessages( } } - if ( - !contentData && - typeof content === 'string' && - /^[0-9a-fA-F]{64,}$/.test(content.trim()) - ) { + if (!contentData && typeof content === 'string' && /^[0-9a-fA-F]{64,}$/.test(content.trim())) { const parsed = parseStickerMessageFromRow(msg, content) if (parsed.type === 'sticker') { if (!parsed.url && parsed.md5) { @@ -324,8 +324,7 @@ export function resolveMd5(query: string): FormattedContact | null { const partial = contacts.find( (c) => - c.m_nsNickName.toLowerCase().includes(lower) || - c.m_nsUsrName.toLowerCase().includes(lower) + c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower) ) return partial || null } @@ -377,32 +376,87 @@ export function getSelfAccountInfo(): SelfAccountInfo | null { } } -export function testConnection( - key: string, - accountRoot?: string -): { success: boolean; error?: string; accountRoot?: string; wxid?: string } { - try { - const probeKey = key.replace(/^0x/i, '').trim() - if (!probeKey) { - return { success: false, error: '密钥不能为空' } - } - const probe = accountRoot ? new WechatDb(probeKey, accountRoot) : new WechatDb(probeKey) - try { - probe.close() - } catch { - // best effort - } - return { - success: true, - accountRoot: probe.getWcdb4Client().getAccountRoot(), - wxid: (probe.getWcdb4Client().getMyUsernameCandidates?.() ?? [])[0] || '' - } - } catch (error) { +export function testConnection(key: string, accountRoot?: string): DatabaseKeyValidationResult { + const probeKey = key.replace(/^0x/i, '').trim() + if (!/^[0-9a-f]{64}$/i.test(probeKey)) { return { success: false, - error: error instanceof Error ? error.message : String(error) + code: 'INVALID_FORMAT', + error: '密钥格式不正确' } } + + let probe: WechatDb | null = null + let ownsProbe = false + try { + const current = dbRef + const canReuseCurrent = + current && + getCurrentKey().replace(/^0x/i, '').trim() === probeKey && + (!accountRoot || getCurrentAccountRoot() === accountRoot) + const validationDb = canReuseCurrent + ? current + : accountRoot + ? new WechatDb(probeKey, accountRoot) + : new WechatDb(probeKey) + probe = validationDb + ownsProbe = validationDb !== current + const client = validationDb.getWcdb4Client() + const contacts = client.getSessions() + const messages = client.getChatTables() + if (contacts[0]) client.getMessages(contacts[0].username, undefined, undefined, { limit: 1 }) + return { + success: true, + accountRoot: client.getAccountRoot(), + wxid: (client.getMyUsernameCandidates?.() ?? [])[0] || '', + contacts: { available: true, count: contacts.length }, + messages: { available: true, count: messages.length } + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + const code = mapConnectionError(detail) + return { + success: false, + code, + error: DATABASE_KEY_ERROR_MESSAGES[code] + } + } finally { + try { + // macOS native shutdown is process-wide. Do not tear down the active reader + // when validating a replacement key; the new runtime connection takes over on save. + if (ownsProbe && !(process.platform === 'darwin' && dbRef)) probe?.close() + } catch { + // Validation probes are best-effort closed without exposing native details. + } + } +} + +const DATABASE_KEY_ERROR_MESSAGES: Record = { + INVALID_FORMAT: '密钥格式不正确', + DATABASE_OPEN_FAILED: '无法打开数据库', + ACCOUNT_MISMATCH: '密钥与当前账号不匹配', + ROOT_UNAVAILABLE: '当前数据库目录不可用', + DATABASE_FILE_MISSING: '数据库文件缺失', + UNKNOWN_VALIDATION_ERROR: '未知验证错误' +} + +function mapConnectionError(detail: string): DatabaseKeyValidationCode { + const normalized = detail.toLowerCase() + if (normalized.includes('-1005') || normalized.includes('不匹配')) return 'ACCOUNT_MISMATCH' + if (normalized.includes('session.db') || normalized.includes('数据库文件')) { + return 'DATABASE_FILE_MISSING' + } + if ( + normalized.includes('数据目录') || + normalized.includes('账号目录') || + normalized.includes('db_storage') + ) { + return 'ROOT_UNAVAILABLE' + } + if (normalized.includes('wcdb_open_account') || normalized.includes('open')) { + return 'DATABASE_OPEN_FAILED' + } + return 'UNKNOWN_VALIDATION_ERROR' } export function reopenWithRoot(accountRoot: string): boolean { diff --git a/src/main/services/wechat-process-status.ts b/src/main/services/wechat-process-status.ts new file mode 100644 index 0000000..2f3c17c --- /dev/null +++ b/src/main/services/wechat-process-status.ts @@ -0,0 +1,23 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' + +const execFileAsync = promisify(execFile) +const WINDOWS_WECHAT_IMAGES = ['Weixin.exe', 'WeChat.exe'] + +export async function isWindowsWechatRunning(): Promise { + if (process.platform !== 'win32') return false + + for (const imageName of WINDOWS_WECHAT_IMAGES) { + try { + const { stdout } = await execFileAsync( + 'tasklist', + ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'], + { windowsHide: true } + ) + if (stdout.toLowerCase().includes(`"${imageName.toLowerCase()}"`)) return true + } catch { + // Try the other supported WeChat executable name. + } + } + return false +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 40f17dc..2e270e8 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -8,6 +8,11 @@ import { SaveGeneratedReportRequest, SaveGeneratedReportResult } from '../shared/report-history' +import type { + DatabaseKeyEnvironment, + DatabaseKeyStorageResult, + DatabaseKeyValidationResult +} from '../shared/database-key' export type ParsedContent = | { type: 'text'; content: string } @@ -113,8 +118,14 @@ declare global { ) => Promise deleteGeneratedReport: (reportId: string) => Promise revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> - getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> - autoGetDbKey: () => Promise<{ + getSavedDbKey: () => Promise + getDatabaseKeyEnvironment: () => Promise + readDatabaseKeyClipboard: () => Promise<{ + success: boolean + value?: string + error?: string + }> + autoGetDbKey: (options?: { save?: boolean }) => Promise<{ success: boolean key?: string error?: string @@ -141,7 +152,7 @@ declare global { } }> pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> - saveDbKey: (key: string) => Promise<{ success: boolean; key?: string; error?: string }> + saveDbKey: (key: string) => Promise clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void @@ -187,15 +198,7 @@ declare global { } | { ready: false } > - testConnection: ( - key: string, - accountRoot?: string - ) => Promise<{ - success: boolean - error?: string - accountRoot?: string - wxid?: string - }> + testConnection: (key: string, accountRoot?: string) => Promise reopenWithRoot: (accountRoot: string) => Promise<{ success: boolean error?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 852e057..3499b53 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,7 +44,9 @@ const api = { ipcRenderer.invoke('report:deleteGenerated', reportId), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), - autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), + getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'), + readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'), + autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options), autoGetImageKey: () => ipcRenderer.invoke('key:autoGetImageKey'), pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'), saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1ce6340..7076aca 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -188,9 +188,13 @@ function App(): React.ReactElement { const currentGroupSnapshotRef = React.useRef(null) const syntheticGroupMessagesRef = React.useRef>({}) const groupMemberMetaRef = React.useRef>>({}) + React.useEffect(() => { + if (!reportNotice) return + const timer = window.setTimeout(() => setReportNotice(''), 3200) + return () => window.clearTimeout(timer) + }, [reportNotice]) const selectedContactMd5Ref = React.useRef('') const contactAvatarHydrationRunRef = React.useRef(0) - const reportGeneration = useGroupReportGeneration({ sourceContact: reportSourceContact, summaryDateRange, @@ -204,7 +208,6 @@ function App(): React.ReactElement { const result = await window.api.listGeneratedReports() if (!result.success) { setReportNotice(result.error || '日报历史加载失败') - window.setTimeout(() => setReportNotice(''), 3200) return } const reports = result.reports || [] @@ -214,7 +217,6 @@ function App(): React.ReactElement { ) } catch (error) { setReportNotice(error instanceof Error ? error.message : String(error)) - window.setTimeout(() => setReportNotice(''), 3200) } }, []) @@ -819,12 +821,10 @@ function App(): React.ReactElement { const handleOpenReportWorkspace = (): void => { if (!selectedContact) { setReportNotice('请先选择一个群聊') - window.setTimeout(() => setReportNotice(''), 3200) return } if (!isGroupContact(selectedContact)) { setReportNotice('AI 群聊日报仅支持群聊') - window.setTimeout(() => setReportNotice(''), 3200) return } setReportNotice('') @@ -881,7 +881,6 @@ function App(): React.ReactElement { if (!result.success || !result.record) { setIsSavingGeneratedReport(false) setReportNotice(result.error || '日报保存失败') - window.setTimeout(() => setReportNotice(''), 3200) return } @@ -911,7 +910,6 @@ function App(): React.ReactElement { const openReportResult = (): void => { if (isSavingGeneratedReport) { setReportNotice('日报正在保存,请稍候') - window.setTimeout(() => setReportNotice(''), 2400) return } const targetReportId = latestGeneratedReportId || selectedReportId @@ -1106,10 +1104,12 @@ function App(): React.ReactElement { selfInfo={selfInfo} dbReady={isDatabaseConnected} dbKey={dbKey} - onNotice={(message) => { - setReportNotice(message) - window.setTimeout(() => setReportNotice(''), 3200) - }} + onDbKeyChange={setDbKey} + onDatabaseConnectionChange={setIsDatabaseConnected} + onSelfInfoChange={setSelfInfo} + onContactsChange={setContacts} + onFilteredContactsChange={setFilteredContacts} + onNotice={setReportNotice} onOpenSettings={openSettings} /> ) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 8743494..d192f26 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -3609,6 +3609,42 @@ body { .settings-diagnostic small { margin-left:auto; overflow:hidden; color:#66706b; text-overflow:ellipsis; white-space:nowrap; } .settings-empty-state { display:grid; flex:1; place-content:center; color:#66706b; text-align:center; } .settings-empty-state h2 { color:#202724; } +/* SETTINGS-02: database key */ +.database-key-content { padding-bottom:48px; } +.database-key-badge.unconfigured { background:#f1f3f2; color:#66706b; } +.database-key-badge.validating { background:#eef4f8; color:#4f7188; } +.database-key-badge.invalid { background:#f9eeee; color:#c85a5a; } +.database-key-status-card dl { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px 36px; margin:0 0 20px; } +.database-key-status-card dl div { min-width:0; } +.database-key-status-card dt,.database-key-diagnostics dt { color:#929a96; font-size:12px; } +.database-key-status-card dd,.database-key-diagnostics dd { overflow:hidden; margin:5px 0 0; color:#35403b; font-size:13px; text-overflow:ellipsis; white-space:nowrap; } +.database-key-mono { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; } +.database-key-success { color:#2e8b68!important; }.database-key-muted { color:#66706b!important; } +.database-key-primary,.database-key-secondary { min-height:36px; padding:0 15px; border:1px solid #247a63; border-radius:8px; cursor:pointer; font:600 13px/1 inherit; } +.database-key-primary { background:#247a63; color:#fff; }.database-key-secondary { background:#fff; color:#247a63; } +.database-key-primary:hover:not(:disabled) { background:#1d6754; }.database-key-secondary:hover:not(:disabled) { background:#f0f7f4; } +.database-key-primary:disabled,.database-key-secondary:disabled { cursor:not-allowed; opacity:.45; } +.database-key-editor label { display:block; margin-bottom:9px; color:#46514c; font-size:12px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; } +.database-key-input-row { display:flex; min-width:0; height:42px; border:1px solid #d5ddda; border-radius:8px; background:#f4f7f5; } +.database-key-input-row:focus-within { border-color:#247a63; box-shadow:0 0 0 2px rgba(36,122,99,.09); } +.database-key-input-row input { min-width:0; flex:1; border:0; outline:0; padding:0 14px; background:transparent; color:#202724; font:13px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; } +.database-key-input-row button { display:grid; width:38px; flex:0 0 38px; place-items:center; border:0; background:transparent; color:#66706b; cursor:pointer; } +.database-key-input-row button:hover:not(:disabled) { color:#247a63; }.database-key-input-row button:disabled { cursor:not-allowed; opacity:.4; } +.database-key-input-row svg { width:17px; fill:none; stroke:currentColor; stroke-linecap:round; stroke-linejoin:round; stroke-width:1.8; } +.database-key-editor>p { margin:9px 0 0; color:#66706b; font-size:12px; line-height:1.6; } +.database-key-actions { display:flex; gap:9px; margin-top:18px; } +.database-key-feedback { display:flex; flex-wrap:wrap; align-items:center; gap:8px 18px; margin-top:12px; padding:12px 15px; border-radius:8px; font-size:12px; line-height:1.5; } +.database-key-feedback.checking { background:#f1f4f3; color:#66706b; }.database-key-feedback.checking i { width:14px; height:14px; border:2px solid #c8d5d0; border-top-color:#247a63; border-radius:50%; animation:settings-spin .8s linear infinite; } +.database-key-feedback.success { border:1px solid #bfded3; background:#eaf5f1; color:#2e765d; }.database-key-feedback.success strong { width:100%; } +.database-key-feedback.error { border:1px solid #efcaca; background:#fff2f2; color:#a84444; } +.database-key-auto-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; }.database-key-auto strong { color:#35403b; font-size:14px; }.database-key-auto p { margin:6px 0 0; color:#66706b; font-size:12px; line-height:1.6; } +.database-key-prerequisites { display:flex; flex-wrap:wrap; gap:8px 20px; margin:18px 0 0; padding:14px 0 0; border-top:1px solid #edf0ee; list-style:none; color:#8b5c25; font-size:12px; }.database-key-prerequisites li::before { content:'○'; margin-right:6px; }.database-key-prerequisites li.ok { color:#2e8b68; }.database-key-prerequisites li.ok::before { content:'✓'; } +.database-key-phases { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:8px; margin:18px 0 0; padding:0; counter-reset:key-phase; list-style:none; }.database-key-phases li { position:relative; padding-top:24px; color:#929a96; text-align:center; font-size:11px; }.database-key-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(key-phase); counter-increment:key-phase; transform:translateX(-50%); }.database-key-phases li.active { color:#247a63; }.database-key-phases li.active::before { border-color:#247a63; background:#247a63; color:#fff; } +.database-key-auto-error { display:grid; gap:4px; margin-top:16px; padding:12px 14px; border-left:3px solid #c68635; background:#fff8ec; color:#79501f; font-size:12px; }.database-key-auto-error span { overflow-wrap:anywhere; }.database-key-auto-error p { color:#79501f; }.database-key-auto-error button { justify-self:start; border:0; padding:4px 0; background:transparent; color:#247a63; cursor:pointer; font:600 12px inherit; } +.database-key-security-info { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1px; overflow:hidden; border:1px solid #e3e8e5; border-radius:9px; background:#e3e8e5; }.database-key-security-info span { display:grid; gap:6px; padding:15px; background:#f5f7f6; color:#66706b; font-size:12px; line-height:1.5; }.database-key-security-info strong { color:#35403b; font-size:13px; } +.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; } @keyframes settings-spin { to { transform:rotate(360deg); } } @media (max-width:900px) { .settings-sidebar { width:248px; flex-basis:248px; } @@ -3617,11 +3653,14 @@ body { .settings-account-overview { grid-template-columns:minmax(0,1fr) auto; gap:18px; } .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; } } @media (max-width:700px) { .settings-account-overview { grid-template-columns:minmax(0,1fr); } .settings-account-actions { grid-row:auto; grid-column:1; grid-template-columns:repeat(2,minmax(0,1fr)); } .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; } } .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 fdd5ce8..4bb04de 100644 --- a/src/renderer/src/features/settings/SettingsWorkspace.tsx +++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx @@ -3,6 +3,8 @@ import { SettingsSidebar } from './components/SettingsSidebar' 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 type { Contact } from '../../../../shared/types' export function SettingsWorkspace({ selectedCategory, @@ -10,6 +12,11 @@ export function SettingsWorkspace({ selfInfo, dbReady, dbKey, + onDbKeyChange, + onDatabaseConnectionChange, + onSelfInfoChange, + onContactsChange, + onFilteredContactsChange, onNotice, onOpenSettings }: { @@ -18,6 +25,11 @@ export function SettingsWorkspace({ selfInfo: SettingsSelfInfo | null dbReady: boolean dbKey: string + onDbKeyChange: (key: string) => void + onDatabaseConnectionChange: (connected: boolean) => void + onSelfInfoChange: (info: SettingsSelfInfo | null) => void + onContactsChange: (contacts: Contact[]) => void + onFilteredContactsChange: (contacts: Contact[]) => void onNotice: (message: string) => void onOpenSettings: () => void }): React.ReactElement { @@ -37,6 +49,18 @@ export function SettingsWorkspace({ selfInfo={selfInfo} onNotice={onNotice} /> + ) : selectedCategory === 'database-key' ? ( + ) : ( )} diff --git a/src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect.tsx b/src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect.tsx new file mode 100644 index 0000000..a4ef1d8 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect.tsx @@ -0,0 +1,76 @@ +import type { DatabaseKeyState } from './types' + +const PHASES = ['查找微信进程', '识别微信版本', '扫描候选密钥', '验证数据库', '获取完成'] + +export function DatabaseKeyAutoDetect({ + state, + disabled, + onDetect, + onRefresh +}: { + state: DatabaseKeyState + disabled: boolean + onDetect: () => void + onRefresh: () => void +}): React.ReactElement { + const environment = state.environment + const platform = environment?.platform || window.electron.process.platform + if (platform !== 'win32') { + return ( +
+
+ 当前 macOS 版本需要手动输入数据库密钥。 +

自动获取未在此平台开放,这不会影响手动验证与系统安全存储。

+
+
+ ) + } + return ( +
+
+
+ Windows 自动获取 +

WechatExplorer 可在微信桌面端正在运行时,通过本机内存扫描尝试获取数据库密钥。

+
+ +
+
    +
  • + 微信进程:{environment?.wechatRunning ? '正在运行' : '未检测到'} +
  • +
  • + 当前账号:{environment?.accountIdentified ? '已识别' : '尚未识别'} +
  • +
  • + 当前平台:{platform === 'win32' ? '支持' : '不支持'} +
  • +
+ {state.status === 'auto-detecting' && ( +
    + {PHASES.map((phase, index) => ( +
  1. = index + 1 ? 'active' : ''}> + {phase} +
  2. + ))} +
+ )} + {state.status === 'auto-detect-error' && ( +
+ 暂未找到有效密钥 + {state.error} +

请保持微信正在运行,登录目标账号并打开几个聊天窗口后重试。

+ +
+ )} +
+ ) +} diff --git a/src/renderer/src/features/settings/database-key/DatabaseKeyDangerZone.tsx b/src/renderer/src/features/settings/database-key/DatabaseKeyDangerZone.tsx new file mode 100644 index 0000000..4c3af9d --- /dev/null +++ b/src/renderer/src/features/settings/database-key/DatabaseKeyDangerZone.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' + +export function DatabaseKeyDangerZone({ + disabled, + onClear, + onReplace +}: { + disabled: boolean + onClear: () => void + onReplace: () => void +}): React.ReactElement { + const [confirming, setConfirming] = useState(false) + return ( + <> +
+

密钥管理

+
+ + 清除已保存密钥 + 从系统安全存储中删除密钥,不会删除微信数据库文件。 + + +
+
+ + 替换当前密钥 + 回到编辑区输入并验证新的数据库密钥。 + + +
+
+ {confirming && ( +
setConfirming(false)} + > +
event.stopPropagation()} + > +

确认清除数据库密钥?

+

+ 清除后 WechatExplorer + 将暂时无法读取聊天记录,需要重新输入或获取密钥。该操作不会删除微信原始数据。 +

+
+ + +
+
+
+ )} + + ) +} diff --git a/src/renderer/src/features/settings/database-key/DatabaseKeyDiagnostics.tsx b/src/renderer/src/features/settings/database-key/DatabaseKeyDiagnostics.tsx new file mode 100644 index 0000000..f6f20f0 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/DatabaseKeyDiagnostics.tsx @@ -0,0 +1,65 @@ +import type { DatabaseKeyState } from './types' +import { formatValidationTime, isDatabaseKeyFormatValid } from './utils' + +export function DatabaseKeyDiagnostics({ + state, + input, + wxid, + accountRoot, + onCopy +}: { + state: DatabaseKeyState + input: string + wxid?: string + accountRoot?: string + onCopy: () => void +}): React.ReactElement { + return ( +
+ 查看密钥诊断 +
+
+
是否已保存
+
{state.saved ? '是' : '否'}
+
+
+
是否已验证
+
{state.validation?.success ? '是' : '否'}
+
+
+
密钥长度是否合法
+
{isDatabaseKeyFormatValid(input) ? '是' : '否'}
+
+
+
当前账号 wxid
+
{wxid || state.validation?.wxid || '未识别'}
+
+
+
当前数据库目录
+
+ {accountRoot || state.validation?.accountRoot || '未识别'} +
+
+
+
最近验证时间
+
{formatValidationTime(state.lastValidatedAt)}
+
+
+
安全错误代码
+
{state.errorCode || '无'}
+
+
+
当前平台
+
{state.environment?.platform || '未知'}
+
+
+
自动获取
+
{state.environment?.autoDetectSupported ? '支持' : '不支持'}
+
+
+ +
+ ) +} diff --git a/src/renderer/src/features/settings/database-key/DatabaseKeyEditor.tsx b/src/renderer/src/features/settings/database-key/DatabaseKeyEditor.tsx new file mode 100644 index 0000000..2990211 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/DatabaseKeyEditor.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react' + +function EyeIcon({ visible }: { visible: boolean }): React.ReactElement { + return visible ? ( + + + + + ) : ( + + + + ) +} + +export function DatabaseKeyEditor({ + value, + disabled, + canSave, + onChange, + onPaste, + onValidate, + onSave +}: { + value: string + disabled: boolean + canSave: boolean + onChange: (value: string) => void + onPaste: () => void + onValidate: () => void + onSave: () => void +}): React.ReactElement { + const [visible, setVisible] = useState(false) + return ( +
+ +
+ onChange(event.target.value)} + placeholder="输入 64 位十六进制数据库密钥" + autoComplete="off" + spellCheck={false} + /> + + + +
+

保存前会验证密钥格式、数据库可读性和当前账号身份。

+
+ + +
+
+ ) +} diff --git a/src/renderer/src/features/settings/database-key/DatabaseKeyStatus.tsx b/src/renderer/src/features/settings/database-key/DatabaseKeyStatus.tsx new file mode 100644 index 0000000..588bf46 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/DatabaseKeyStatus.tsx @@ -0,0 +1,58 @@ +import type { SettingsSelfInfo } from '../model/types' +import type { DatabaseKeyState } from './types' +import { formatValidationTime } from './utils' + +export function DatabaseKeyStatus({ + state, + dbReady, + selfInfo, + disabled, + onValidate +}: { + state: DatabaseKeyState + dbReady: boolean + selfInfo: SettingsSelfInfo | null + disabled: boolean + onValidate: () => void +}): React.ReactElement { + return ( +
+
+
+
密钥状态
+
{state.saved ? '已配置' : '未配置'}
+
+
+
存储方式
+
{state.encryptionAvailable ? '系统安全存储' : '系统安全存储不可用'}
+
+
+
当前账号
+
{selfInfo?.nickname || '尚未识别'}
+
+
+
wxid
+
{selfInfo?.wxid || state.validation?.wxid || '—'}
+
+
+
最近验证
+
{formatValidationTime(state.lastValidatedAt)}
+
+
+
数据库状态
+
+ {dbReady ? '连接正常' : '尚未连接'} +
+
+
+ +
+ ) +} diff --git a/src/renderer/src/features/settings/database-key/DatabaseKeyValidation.tsx b/src/renderer/src/features/settings/database-key/DatabaseKeyValidation.tsx new file mode 100644 index 0000000..aaa7ea8 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/DatabaseKeyValidation.tsx @@ -0,0 +1,36 @@ +import type { DatabaseKeyState } from './types' + +export function DatabaseKeyValidation({ + state +}: { + state: DatabaseKeyState +}): React.ReactElement | null { + if ( + !['validating', 'valid', 'invalid', 'save-error', 'clear-error', 'saved'].includes(state.status) + ) { + return null + } + if (state.status === 'validating') { + return ( +
+ + 正在验证数据库密钥…… +
+ ) + } + if (['invalid', 'save-error', 'clear-error'].includes(state.status)) { + return
{state.error || '密钥验证失败'}
+ } + if (!state.validation?.success) return null + return ( +
+ 密钥验证成功 + 已识别账号:{state.validation.wxid || '已识别'} + 联系人数据库:{state.validation.contacts?.available ? '可用' : '不可用'} + 消息数据库:{state.validation.messages?.available ? '可用' : '不可用'} + + 账号目录:{state.validation.accountRoot || '已识别'} + +
+ ) +} diff --git a/src/renderer/src/features/settings/database-key/databaseKeyReducer.ts b/src/renderer/src/features/settings/database-key/databaseKeyReducer.ts new file mode 100644 index 0000000..c300469 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/databaseKeyReducer.ts @@ -0,0 +1,90 @@ +import type { DatabaseKeyAction, DatabaseKeyState } from './types' + +export const initialDatabaseKeyState: DatabaseKeyState = { + status: 'idle', + saved: false, + encryptionAvailable: true, + autoPhase: 0 +} + +export function databaseKeyReducer( + state: DatabaseKeyState, + action: DatabaseKeyAction +): DatabaseKeyState { + switch (action.type) { + case 'STORAGE_LOADED': + return { + ...state, + saved: action.saved, + encryptionAvailable: action.encryptionAvailable, + status: state.status === 'idle' && action.saved ? 'saved' : state.status, + error: action.error + } + case 'ENVIRONMENT_LOADED': + return { ...state, environment: action.environment } + case 'EDIT': + return { + ...state, + status: 'editing', + validation: undefined, + error: undefined, + errorCode: undefined + } + case 'VALIDATE_START': + return { + ...state, + status: 'validating', + validation: undefined, + error: undefined, + errorCode: undefined + } + case 'VALIDATE_SUCCESS': + return { + ...state, + status: 'valid', + validation: action.result, + lastValidatedAt: action.at, + error: undefined, + errorCode: undefined, + autoPhase: Math.max(state.autoPhase, 5) + } + case 'VALIDATE_ERROR': + return { + ...state, + status: 'invalid', + validation: action.result, + lastValidatedAt: action.at, + error: action.result.error, + errorCode: action.result.code + } + case 'SAVE_START': + return { ...state, status: 'saving', error: undefined } + case 'SAVE_SUCCESS': + return { + ...state, + status: 'saved', + saved: true, + encryptionAvailable: action.encryptionAvailable + } + case 'SAVE_ERROR': + return { ...state, status: 'save-error', error: action.error } + case 'CLEAR_START': + return { ...state, status: 'clearing', error: undefined } + case 'CLEAR_SUCCESS': + return { + ...initialDatabaseKeyState, + environment: state.environment, + encryptionAvailable: state.encryptionAvailable + } + case 'CLEAR_ERROR': + return { ...state, status: 'clear-error', error: action.error } + case 'AUTO_START': + return { ...state, status: 'auto-detecting', autoPhase: 1, error: undefined } + case 'AUTO_PROGRESS': + return { ...state, autoPhase: Math.max(state.autoPhase, action.phase) } + case 'AUTO_SUCCESS': + return { ...state, status: 'auto-detected', autoPhase: 4 } + case 'AUTO_ERROR': + return { ...state, status: 'auto-detect-error', error: action.error } + } +} diff --git a/src/renderer/src/features/settings/database-key/types.ts b/src/renderer/src/features/settings/database-key/types.ts new file mode 100644 index 0000000..7d37929 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/types.ts @@ -0,0 +1,64 @@ +import type { + DatabaseKeyEnvironment, + DatabaseKeyValidationResult +} from '../../../../../shared/database-key' + +export type DatabaseKeyWorkflowStatus = + | 'idle' + | 'editing' + | 'validating' + | 'valid' + | 'invalid' + | 'saving' + | 'saved' + | 'save-error' + | 'clearing' + | 'clear-error' + | 'auto-detecting' + | 'auto-detected' + | 'auto-detect-error' + +export interface DatabaseKeyState { + status: DatabaseKeyWorkflowStatus + saved: boolean + encryptionAvailable: boolean + validation?: DatabaseKeyValidationResult + lastValidatedAt?: number + error?: string + errorCode?: string + autoPhase: number + environment?: DatabaseKeyEnvironment +} + +export type DatabaseKeyAction = + | { type: 'STORAGE_LOADED'; saved: boolean; encryptionAvailable: boolean; error?: string } + | { type: 'ENVIRONMENT_LOADED'; environment: DatabaseKeyEnvironment } + | { type: 'EDIT' } + | { type: 'VALIDATE_START' } + | { type: 'VALIDATE_SUCCESS'; result: DatabaseKeyValidationResult; at: number } + | { type: 'VALIDATE_ERROR'; result: DatabaseKeyValidationResult; at: number } + | { type: 'SAVE_START' } + | { type: 'SAVE_SUCCESS'; encryptionAvailable: boolean } + | { type: 'SAVE_ERROR'; error: string } + | { type: 'CLEAR_START' } + | { type: 'CLEAR_SUCCESS' } + | { type: 'CLEAR_ERROR'; error: string } + | { type: 'AUTO_START' } + | { type: 'AUTO_PROGRESS'; phase: number } + | { type: 'AUTO_SUCCESS' } + | { type: 'AUTO_ERROR'; error: string } + +export interface DatabaseKeyController { + state: DatabaseKeyState + isBusy: boolean + canSave: boolean + pageStatus: 'saved' | 'unconfigured' | 'validating' | 'invalid' + editKey: (value: string) => void + pasteKey: () => Promise + validateKey: () => Promise + saveKey: () => Promise + autoDetectKey: () => Promise + clearSavedKey: () => Promise + copyDiagnostics: () => Promise + refreshEnvironment: () => Promise +} diff --git a/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts b/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts new file mode 100644 index 0000000..abb8f91 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useMemo, useReducer } from 'react' +import type { Contact } from '../../../../../shared/types' +import type { SettingsSelfInfo } from '../model/types' +import { databaseKeyReducer, initialDatabaseKeyState } from './databaseKeyReducer' +import type { DatabaseKeyController } from './types' +import { buildDatabaseKeyDiagnostics, isDatabaseKeyFormatValid, mapAutoDetectPhase } from './utils' + +export function useDatabaseKeyController({ + dbKey, + dbReady, + selfInfo, + onDbKeyChange, + onDatabaseConnectionChange, + onSelfInfoChange, + onContactsChange, + onFilteredContactsChange, + onNotice +}: { + dbKey: string + dbReady: boolean + selfInfo: SettingsSelfInfo | null + onDbKeyChange: (key: string) => void + onDatabaseConnectionChange: (connected: boolean) => void + onSelfInfoChange: (info: SettingsSelfInfo | null) => void + onContactsChange: (contacts: Contact[]) => void + onFilteredContactsChange: (contacts: Contact[]) => void + onNotice: (message: string) => void +}): DatabaseKeyController { + const [state, dispatch] = useReducer(databaseKeyReducer, initialDatabaseKeyState) + + const refreshEnvironment = useCallback(async (): Promise => { + const environment = await window.api.getDatabaseKeyEnvironment() + dispatch({ type: 'ENVIRONMENT_LOADED', environment }) + }, []) + + const refreshStorage = useCallback(async (): Promise => { + const result = await window.api.getSavedDbKey() + dispatch({ + type: 'STORAGE_LOADED', + saved: result.saved, + encryptionAvailable: result.encryptionAvailable, + error: result.success ? undefined : result.error + }) + }, []) + + useEffect(() => { + void Promise.all([refreshStorage(), refreshEnvironment()]) + return window.api.onDbKeyStatus(({ message }) => { + dispatch({ type: 'AUTO_PROGRESS', phase: mapAutoDetectPhase(message) }) + }) + }, [refreshEnvironment, refreshStorage]) + + const editKey = useCallback( + (value: string): void => { + onDbKeyChange(value) + dispatch({ type: 'EDIT' }) + }, + [onDbKeyChange] + ) + + const pasteKey = useCallback(async (): Promise => { + const result = await window.api.readDatabaseKeyClipboard() + if (!result.success || !result.value) { + onNotice(result.error || '剪贴板中没有可用的数据库密钥') + return + } + editKey(result.value) + onNotice('已从剪贴板粘贴,请验证后保存') + }, [editKey, onNotice]) + + const runValidation = useCallback( + async (key: string): Promise => { + dispatch({ type: 'VALIDATE_START' }) + if (!isDatabaseKeyFormatValid(key)) { + dispatch({ + type: 'VALIDATE_ERROR', + at: Date.now(), + result: { success: false, code: 'INVALID_FORMAT', error: '密钥格式不正确' } + }) + return false + } + const result = await window.api.testConnection(key, selfInfo?.accountRoot) + if ( + result.success && + selfInfo?.wxid && + result.wxid && + result.wxid.toLowerCase() !== selfInfo.wxid.toLowerCase() + ) { + result.success = false + result.code = 'ACCOUNT_MISMATCH' + result.error = '密钥与当前账号不匹配' + } + dispatch({ + type: result.success ? 'VALIDATE_SUCCESS' : 'VALIDATE_ERROR', + result, + at: Date.now() + }) + return result.success + }, + [selfInfo] + ) + + const validateKey = useCallback(async (): Promise => { + await runValidation(dbKey) + }, [dbKey, runValidation]) + + const saveKey = useCallback(async (): Promise => { + if (state.status !== 'valid') return + dispatch({ type: 'SAVE_START' }) + const saved = await window.api.saveDbKey(dbKey) + if (!saved.success || !saved.key) { + dispatch({ + type: 'SAVE_ERROR', + error: saved.encryptionAvailable ? '密钥保存失败' : '系统安全存储不可用' + }) + return + } + const stored = await window.api.getSavedDbKey() + if (!stored.success || !stored.saved || !stored.key) { + dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' }) + return + } + const initialized = await window.api.initDb(stored.key) + const connected = typeof initialized === 'boolean' ? initialized : initialized.success + onDbKeyChange(stored.key) + onDatabaseConnectionChange(connected) + if (connected) { + const [self, contacts] = await Promise.all([window.api.getSelf(), window.api.getContacts()]) + onSelfInfoChange(self.ready ? self.info : null) + onContactsChange(contacts) + onFilteredContactsChange(contacts) + } else { + onSelfInfoChange(null) + onContactsChange([]) + onFilteredContactsChange([]) + } + dispatch({ type: 'SAVE_SUCCESS', encryptionAvailable: stored.encryptionAvailable }) + await refreshEnvironment() + onNotice(connected ? '数据库密钥已安全保存' : '密钥已保存,但数据库重新连接失败') + }, [ + dbKey, + onContactsChange, + onDatabaseConnectionChange, + onDbKeyChange, + onFilteredContactsChange, + onNotice, + onSelfInfoChange, + refreshEnvironment, + state.status + ]) + + const autoDetectKey = useCallback(async (): Promise => { + dispatch({ type: 'AUTO_START' }) + await refreshEnvironment() + const result = await window.api.autoGetDbKey({ save: false }) + if (!result.success || !result.key) { + dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' }) + return + } + onDbKeyChange(result.key) + dispatch({ type: 'AUTO_SUCCESS' }) + await runValidation(result.key) + }, [onDbKeyChange, refreshEnvironment, runValidation]) + + const clearSavedKey = useCallback(async (): Promise => { + dispatch({ type: 'CLEAR_START' }) + const result = await window.api.clearSavedDbKey() + if (!result.success) { + dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' }) + return + } + await window.api.disconnectDb() + onDbKeyChange('') + onDatabaseConnectionChange(false) + onSelfInfoChange(null) + onContactsChange([]) + onFilteredContactsChange([]) + dispatch({ type: 'CLEAR_SUCCESS' }) + await refreshEnvironment() + onNotice('数据库密钥已清除') + }, [ + onContactsChange, + onDatabaseConnectionChange, + onDbKeyChange, + onFilteredContactsChange, + onNotice, + onSelfInfoChange, + refreshEnvironment + ]) + + const copyDiagnostics = useCallback(async (): Promise => { + const result = await window.api.copyText( + buildDatabaseKeyDiagnostics(state, dbKey, selfInfo, dbReady) + ) + onNotice(result.success ? '密钥诊断信息已复制' : result.error || '复制诊断信息失败') + }, [dbKey, dbReady, onNotice, selfInfo, state]) + + const isBusy = ['validating', 'saving', 'clearing', 'auto-detecting'].includes(state.status) + const canSave = state.status === 'valid' && state.encryptionAvailable + const pageStatus = useMemo(() => { + if (state.status === 'validating' || state.status === 'auto-detecting') return 'validating' + if (state.status === 'invalid') return 'invalid' + return state.saved ? 'saved' : 'unconfigured' + }, [state.saved, state.status]) + + return { + state, + isBusy, + canSave, + pageStatus, + editKey, + pasteKey, + validateKey, + saveKey, + autoDetectKey, + clearSavedKey, + copyDiagnostics, + refreshEnvironment + } +} diff --git a/src/renderer/src/features/settings/database-key/utils.ts b/src/renderer/src/features/settings/database-key/utils.ts new file mode 100644 index 0000000..6e729e8 --- /dev/null +++ b/src/renderer/src/features/settings/database-key/utils.ts @@ -0,0 +1,43 @@ +import type { DatabaseKeyState } from './types' + +export const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '') + +export const isDatabaseKeyFormatValid = (value: string): boolean => + /^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value)) + +export function mapAutoDetectPhase(message: string): number { + if (/完成|成功|已获取/.test(message)) return 5 + if (/验证|校验/.test(message)) return 4 + if (/扫描|候选|获取/.test(message)) return 3 + if (/版本|组件|窗口/.test(message)) return 2 + return 1 +} + +export function formatValidationTime(timestamp?: number): string { + if (!timestamp) return '尚未验证' + return new Date(timestamp).toLocaleString('zh-CN', { hour12: false }) +} + +export function buildDatabaseKeyDiagnostics( + state: DatabaseKeyState, + input: string, + account: { wxid?: string; accountRoot?: string } | null, + dbReady: boolean +): string { + const validation = state.validation + return [ + 'WechatExplorer 数据库密钥诊断', + `已保存: ${state.saved ? '是' : '否'}`, + `已验证: ${validation?.success ? '是' : '否'}`, + `密钥长度合法: ${isDatabaseKeyFormatValid(input) ? '是' : '否'}`, + `当前账号 wxid: ${account?.wxid || validation?.wxid || '未识别'}`, + `当前数据库目录: ${account?.accountRoot || validation?.accountRoot || '未识别'}`, + `最近验证时间: ${formatValidationTime(state.lastValidatedAt)}`, + `最近验证结果: ${validation?.success ? '成功' : state.error || '未验证'}`, + `安全错误代码: ${state.errorCode || '无'}`, + `数据库连接: ${dbReady ? '已连接' : '未连接'}`, + `当前平台: ${state.environment?.platform || '未知'}`, + `自动获取支持: ${state.environment?.autoDetectSupported ? '是' : '否'}`, + `系统安全存储: ${state.encryptionAvailable ? '可用' : '不可用'}` + ].join('\n') +} diff --git a/src/renderer/src/features/settings/pages/DatabaseKeyPage.tsx b/src/renderer/src/features/settings/pages/DatabaseKeyPage.tsx new file mode 100644 index 0000000..8e4bf8d --- /dev/null +++ b/src/renderer/src/features/settings/pages/DatabaseKeyPage.tsx @@ -0,0 +1,140 @@ +import { DatabaseKeyAutoDetect } from '../database-key/DatabaseKeyAutoDetect' +import { DatabaseKeyDangerZone } from '../database-key/DatabaseKeyDangerZone' +import { DatabaseKeyDiagnostics } from '../database-key/DatabaseKeyDiagnostics' +import { DatabaseKeyEditor } from '../database-key/DatabaseKeyEditor' +import { DatabaseKeyStatus } from '../database-key/DatabaseKeyStatus' +import { DatabaseKeyValidation } from '../database-key/DatabaseKeyValidation' +import { useDatabaseKeyController } from '../database-key/useDatabaseKeyController' +import type { Contact } from '../../../../../shared/types' +import type { SettingsSelfInfo } from '../model/types' + +const STATUS_LABELS = { + saved: '已安全保存', + unconfigured: '尚未配置', + validating: '正在验证', + invalid: '验证失败' +} + +export function DatabaseKeyPage({ + dbKey, + dbReady, + selfInfo, + onDbKeyChange, + onDatabaseConnectionChange, + onSelfInfoChange, + onContactsChange, + onFilteredContactsChange, + onNotice +}: { + dbKey: string + dbReady: boolean + selfInfo: SettingsSelfInfo | null + onDbKeyChange: (key: string) => void + onDatabaseConnectionChange: (connected: boolean) => void + onSelfInfoChange: (info: SettingsSelfInfo | null) => void + onContactsChange: (contacts: Contact[]) => void + onFilteredContactsChange: (contacts: Contact[]) => void + onNotice: (message: string) => void +}): React.ReactElement { + const controller = useDatabaseKeyController({ + dbKey, + dbReady, + selfInfo, + onDbKeyChange, + onDatabaseConnectionChange, + onSelfInfoChange, + onContactsChange, + onFilteredContactsChange, + onNotice + }) + + const scrollToEditor = (): void => { + document.getElementById('database-key-editor')?.scrollIntoView({ behavior: 'smooth' }) + window.setTimeout(() => document.getElementById('wechat-db-key')?.focus(), 250) + } + + return ( +
+
+
+

数据库密钥

+

管理用于读取本机微信数据库的解密密钥

+
+ + {STATUS_LABELS[controller.pageStatus]} + +
+
+
+
+ + + +
+ 密钥仅保存在本机 +

+ WechatExplorer + 使用数据库密钥读取本机微信数据库。密钥通过系统安全存储加密保存,不会写入普通日志,也不会上传到服务器。 +

+
+
+ +

当前状态

+ void controller.validateKey()} + /> + +

编辑密钥

+ void controller.pasteKey()} + onValidate={() => void controller.validateKey()} + onSave={() => void controller.saveKey()} + /> + + +

自动获取密钥

+ void controller.autoDetectKey()} + onRefresh={() => void controller.refreshEnvironment()} + /> + +

安全说明

+
+ + 系统加密密钥通过操作系统安全存储加密保存。 + + + 账号对应验证会确认密钥可读取当前微信账号数据库。 + + + 无损清除清除密钥不会删除任何微信数据库文件。 + +
+ + void controller.copyDiagnostics()} + /> + void controller.clearSavedKey()} + onReplace={scrollToEditor} + /> +
+
+
+ ) +} diff --git a/src/shared/database-key.ts b/src/shared/database-key.ts new file mode 100644 index 0000000..326d4a7 --- /dev/null +++ b/src/shared/database-key.ts @@ -0,0 +1,34 @@ +export type DatabaseKeyValidationCode = + | 'INVALID_FORMAT' + | 'DATABASE_OPEN_FAILED' + | 'ACCOUNT_MISMATCH' + | 'ROOT_UNAVAILABLE' + | 'DATABASE_FILE_MISSING' + | 'UNKNOWN_VALIDATION_ERROR' + +export interface DatabaseKeyValidationResult { + success: boolean + code?: DatabaseKeyValidationCode + error?: string + accountRoot?: string + wxid?: string + contacts?: { available: boolean; count?: number } + messages?: { available: boolean; count?: number } +} + +export interface DatabaseKeyStorageResult { + success: boolean + key?: string + error?: string + saved: boolean + encryptionAvailable: boolean +} + +export interface DatabaseKeyEnvironment { + platform: NodeJS.Platform + autoDetectSupported: boolean + wechatRunning: boolean + accountIdentified: boolean + dbConnected: boolean + encryptionAvailable: boolean +}