实现数据库密钥设置页

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
parent ab24185670
commit f1ceef0e5e
21 changed files with 1264 additions and 77 deletions
+48 -23
View File
@@ -1,12 +1,7 @@
import { app, safeStorage } from 'electron' import { app, safeStorage } from 'electron'
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path' import path from 'path'
import type { DatabaseKeyStorageResult } from '../shared/database-key'
export interface StoredKeyResult {
success: boolean
key?: string
error?: string
}
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '') 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') return path.join(app.getPath('userData'), 'wechat-db-key.bin')
} }
async load(): Promise<StoredKeyResult> { async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
try { return {
if (!(await fs.pathExists(this.filePath))) return { success: true } saved: await fs.pathExists(this.filePath),
if (!safeStorage.isEncryptionAvailable()) { encryptionAvailable: 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 save(rawKey: string): Promise<StoredKeyResult> { async load(): Promise<DatabaseKeyStorageResult> {
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<DatabaseKeyStorageResult> {
const key = normalizeDatabaseKey(rawKey) const key = normalizeDatabaseKey(rawKey)
if (!isValidDatabaseKey(key)) { 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()) { if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' } return {
success: false,
error: '系统安全存储不可用',
saved: await fs.pathExists(this.filePath),
encryptionAvailable: false
}
} }
try { try {
await fs.ensureDir(path.dirname(this.filePath)) await fs.ensureDir(path.dirname(this.filePath))
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 }) await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
await fs.chmod(this.filePath, 0o600) await fs.chmod(this.filePath, 0o600)
return { success: true, key } return { success: true, key, saved: true, encryptionAvailable: true }
} catch (error) { } 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
}
} }
} }
+25 -1
View File
@@ -34,6 +34,7 @@ import * as chat from './services/chat-service'
import { apiServer } from './http-server' 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 { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store' import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
import { import {
getBootstrapCache, getBootstrapCache,
@@ -194,6 +195,27 @@ app.whenReady().then(async () => {
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load()) 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 () => { ipcMain.handle('key:pasteAndSaveDbKey', async () => {
const clipboardKey = clipboard.readText().trim() const clipboardKey = clipboard.readText().trim()
return databaseKeyStore.save(clipboardKey) return databaseKeyStore.save(clipboardKey)
@@ -205,7 +227,7 @@ app.whenReady().then(async () => {
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear()) 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 => { const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message }) if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
} }
@@ -215,6 +237,8 @@ app.whenReady().then(async () => {
: await keyServiceMac.autoGetDbKey(onStatus) : await keyServiceMac.autoGetDbKey(onStatus)
if (!result.success || !result.key) return result if (!result.success || !result.key) return result
if (options?.save === false) return result
const saved = await databaseKeyStore.save(result.key) const saved = await databaseKeyStore.save(result.key)
return { return {
...result, ...result,
+83 -29
View File
@@ -4,6 +4,10 @@ import {
parseMessageContent, parseMessageContent,
parseStickerMessageFromRow parseStickerMessageFromRow
} from '../message-parser' } from '../message-parser'
import type {
DatabaseKeyValidationCode,
DatabaseKeyValidationResult
} from '../../shared/database-key'
export function getCurrentKey(): string { export function getCurrentKey(): string {
if (!dbRef) return '' if (!dbRef) return ''
@@ -239,11 +243,7 @@ export function listMessages(
} }
} }
if ( if (!contentData && typeof content === 'string' && /^[0-9a-fA-F]{64,}$/.test(content.trim())) {
!contentData &&
typeof content === 'string' &&
/^[0-9a-fA-F]{64,}$/.test(content.trim())
) {
const parsed = parseStickerMessageFromRow(msg, content) const parsed = parseStickerMessageFromRow(msg, content)
if (parsed.type === 'sticker') { if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5) { if (!parsed.url && parsed.md5) {
@@ -324,8 +324,7 @@ export function resolveMd5(query: string): FormattedContact | null {
const partial = contacts.find( const partial = contacts.find(
(c) => (c) =>
c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
c.m_nsUsrName.toLowerCase().includes(lower)
) )
return partial || null return partial || null
} }
@@ -377,32 +376,87 @@ export function getSelfAccountInfo(): SelfAccountInfo | null {
} }
} }
export function testConnection( export function testConnection(key: string, accountRoot?: string): DatabaseKeyValidationResult {
key: string, const probeKey = key.replace(/^0x/i, '').trim()
accountRoot?: string if (!/^[0-9a-f]{64}$/i.test(probeKey)) {
): { 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) {
return { return {
success: false, 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<DatabaseKeyValidationCode, string> = {
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 { export function reopenWithRoot(accountRoot: string): boolean {
@@ -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<boolean> {
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
}
+15 -12
View File
@@ -8,6 +8,11 @@ import {
SaveGeneratedReportRequest, SaveGeneratedReportRequest,
SaveGeneratedReportResult SaveGeneratedReportResult
} from '../shared/report-history' } from '../shared/report-history'
import type {
DatabaseKeyEnvironment,
DatabaseKeyStorageResult,
DatabaseKeyValidationResult
} from '../shared/database-key'
export type ParsedContent = export type ParsedContent =
| { type: 'text'; content: string } | { type: 'text'; content: string }
@@ -113,8 +118,14 @@ declare global {
) => Promise<SaveGeneratedReportResult> ) => Promise<SaveGeneratedReportResult>
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult> deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> getSavedDbKey: () => Promise<DatabaseKeyStorageResult>
autoGetDbKey: () => Promise<{ getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
readDatabaseKeyClipboard: () => Promise<{
success: boolean
value?: string
error?: string
}>
autoGetDbKey: (options?: { save?: boolean }) => Promise<{
success: boolean success: boolean
key?: string key?: string
error?: string error?: string
@@ -141,7 +152,7 @@ declare global {
} }
}> }>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
saveDbKey: (key: string) => Promise<{ success: boolean; key?: string; error?: string }> saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult>
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
@@ -187,15 +198,7 @@ declare global {
} }
| { ready: false } | { ready: false }
> >
testConnection: ( testConnection: (key: string, accountRoot?: string) => Promise<DatabaseKeyValidationResult>
key: string,
accountRoot?: string
) => Promise<{
success: boolean
error?: string
accountRoot?: string
wxid?: string
}>
reopenWithRoot: (accountRoot: string) => Promise<{ reopenWithRoot: (accountRoot: string) => Promise<{
success: boolean success: boolean
error?: string error?: string
+3 -1
View File
@@ -44,7 +44,9 @@ const api = {
ipcRenderer.invoke('report:deleteGenerated', reportId), ipcRenderer.invoke('report:deleteGenerated', reportId),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), 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'), autoGetImageKey: () => ipcRenderer.invoke('key:autoGetImageKey'),
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),
+11 -11
View File
@@ -188,9 +188,13 @@ function App(): React.ReactElement {
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null) const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({}) const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({}) const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
React.useEffect(() => {
if (!reportNotice) return
const timer = window.setTimeout(() => setReportNotice(''), 3200)
return () => window.clearTimeout(timer)
}, [reportNotice])
const selectedContactMd5Ref = React.useRef<string>('') const selectedContactMd5Ref = React.useRef<string>('')
const contactAvatarHydrationRunRef = React.useRef(0) const contactAvatarHydrationRunRef = React.useRef(0)
const reportGeneration = useGroupReportGeneration({ const reportGeneration = useGroupReportGeneration({
sourceContact: reportSourceContact, sourceContact: reportSourceContact,
summaryDateRange, summaryDateRange,
@@ -204,7 +208,6 @@ function App(): React.ReactElement {
const result = await window.api.listGeneratedReports() const result = await window.api.listGeneratedReports()
if (!result.success) { if (!result.success) {
setReportNotice(result.error || '日报历史加载失败') setReportNotice(result.error || '日报历史加载失败')
window.setTimeout(() => setReportNotice(''), 3200)
return return
} }
const reports = result.reports || [] const reports = result.reports || []
@@ -214,7 +217,6 @@ function App(): React.ReactElement {
) )
} catch (error) { } catch (error) {
setReportNotice(error instanceof Error ? error.message : String(error)) setReportNotice(error instanceof Error ? error.message : String(error))
window.setTimeout(() => setReportNotice(''), 3200)
} }
}, []) }, [])
@@ -819,12 +821,10 @@ function App(): React.ReactElement {
const handleOpenReportWorkspace = (): void => { const handleOpenReportWorkspace = (): void => {
if (!selectedContact) { if (!selectedContact) {
setReportNotice('请先选择一个群聊') setReportNotice('请先选择一个群聊')
window.setTimeout(() => setReportNotice(''), 3200)
return return
} }
if (!isGroupContact(selectedContact)) { if (!isGroupContact(selectedContact)) {
setReportNotice('AI 群聊日报仅支持群聊') setReportNotice('AI 群聊日报仅支持群聊')
window.setTimeout(() => setReportNotice(''), 3200)
return return
} }
setReportNotice('') setReportNotice('')
@@ -881,7 +881,6 @@ function App(): React.ReactElement {
if (!result.success || !result.record) { if (!result.success || !result.record) {
setIsSavingGeneratedReport(false) setIsSavingGeneratedReport(false)
setReportNotice(result.error || '日报保存失败') setReportNotice(result.error || '日报保存失败')
window.setTimeout(() => setReportNotice(''), 3200)
return return
} }
@@ -911,7 +910,6 @@ function App(): React.ReactElement {
const openReportResult = (): void => { const openReportResult = (): void => {
if (isSavingGeneratedReport) { if (isSavingGeneratedReport) {
setReportNotice('日报正在保存,请稍候') setReportNotice('日报正在保存,请稍候')
window.setTimeout(() => setReportNotice(''), 2400)
return return
} }
const targetReportId = latestGeneratedReportId || selectedReportId const targetReportId = latestGeneratedReportId || selectedReportId
@@ -1106,10 +1104,12 @@ function App(): React.ReactElement {
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
dbKey={dbKey} dbKey={dbKey}
onNotice={(message) => { onDbKeyChange={setDbKey}
setReportNotice(message) onDatabaseConnectionChange={setIsDatabaseConnected}
window.setTimeout(() => setReportNotice(''), 3200) onSelfInfoChange={setSelfInfo}
}} onContactsChange={setContacts}
onFilteredContactsChange={setFilteredContacts}
onNotice={setReportNotice}
onOpenSettings={openSettings} onOpenSettings={openSettings}
/> />
) )
+39
View File
@@ -3609,6 +3609,42 @@ body {
.settings-diagnostic small { margin-left:auto; overflow:hidden; color:#66706b; text-overflow:ellipsis; white-space:nowrap; } .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 { display:grid; flex:1; place-content:center; color:#66706b; text-align:center; }
.settings-empty-state h2 { color:#202724; } .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); } } @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; }
@@ -3617,11 +3653,14 @@ body {
.settings-account-overview { grid-template-columns:minmax(0,1fr) auto; gap:18px; } .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-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; }
} }
@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); }
.settings-account-actions { grid-row:auto; grid-column:1; grid-template-columns:repeat(2,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; } .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-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); }
@@ -3,6 +3,8 @@ import { SettingsSidebar } from './components/SettingsSidebar'
import { SETTINGS_CATEGORY_LABELS } from './model/settingsNavigation' 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 type { Contact } from '../../../../shared/types'
export function SettingsWorkspace({ export function SettingsWorkspace({
selectedCategory, selectedCategory,
@@ -10,6 +12,11 @@ export function SettingsWorkspace({
selfInfo, selfInfo,
dbReady, dbReady,
dbKey, dbKey,
onDbKeyChange,
onDatabaseConnectionChange,
onSelfInfoChange,
onContactsChange,
onFilteredContactsChange,
onNotice, onNotice,
onOpenSettings onOpenSettings
}: { }: {
@@ -18,6 +25,11 @@ export function SettingsWorkspace({
selfInfo: SettingsSelfInfo | null selfInfo: SettingsSelfInfo | null
dbReady: boolean dbReady: boolean
dbKey: string 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 onNotice: (message: string) => void
onOpenSettings: () => void onOpenSettings: () => void
}): React.ReactElement { }): React.ReactElement {
@@ -37,6 +49,18 @@ export function SettingsWorkspace({
selfInfo={selfInfo} selfInfo={selfInfo}
onNotice={onNotice} onNotice={onNotice}
/> />
) : selectedCategory === 'database-key' ? (
<DatabaseKeyPage
dbKey={dbKey}
dbReady={dbReady}
selfInfo={selfInfo}
onDbKeyChange={onDbKeyChange}
onDatabaseConnectionChange={onDatabaseConnectionChange}
onSelfInfoChange={onSelfInfoChange}
onContactsChange={onContactsChange}
onFilteredContactsChange={onFilteredContactsChange}
onNotice={onNotice}
/>
) : ( ) : (
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} /> <SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
)} )}
@@ -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 (
<section className="settings-card database-key-auto database-key-auto-manual">
<div>
<strong> macOS </strong>
<p></p>
</div>
</section>
)
}
return (
<section className="settings-card database-key-auto">
<div className="database-key-auto-heading">
<div>
<strong>Windows </strong>
<p>WechatExplorer </p>
</div>
<button
type="button"
className="database-key-secondary"
onClick={onDetect}
disabled={disabled}
>
{state.status === 'auto-detecting' ? '正在获取…' : '自动获取密钥'}
</button>
</div>
<ul className="database-key-prerequisites">
<li className={environment?.wechatRunning ? 'ok' : ''}>
{environment?.wechatRunning ? '正在运行' : '未检测到'}
</li>
<li className={environment?.accountIdentified ? 'ok' : ''}>
{environment?.accountIdentified ? '已识别' : '尚未识别'}
</li>
<li className={platform === 'win32' ? 'ok' : ''}>
{platform === 'win32' ? '支持' : '不支持'}
</li>
</ul>
{state.status === 'auto-detecting' && (
<ol className="database-key-phases">
{PHASES.map((phase, index) => (
<li key={phase} className={state.autoPhase >= index + 1 ? 'active' : ''}>
{phase}
</li>
))}
</ol>
)}
{state.status === 'auto-detect-error' && (
<div className="database-key-auto-error">
<strong></strong>
<span>{state.error}</span>
<p></p>
<button type="button" onClick={onRefresh}>
</button>
</div>
)}
</section>
)
}
@@ -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 (
<>
<section className="database-key-danger">
<h2></h2>
<div>
<span>
<strong></strong>
<small></small>
</span>
<button type="button" onClick={() => setConfirming(true)} disabled={disabled}>
</button>
</div>
<div>
<span>
<strong></strong>
<small></small>
</span>
<button type="button" onClick={onReplace} disabled={disabled}>
</button>
</div>
</section>
{confirming && (
<div
className="database-key-confirm-backdrop"
role="presentation"
onMouseDown={() => setConfirming(false)}
>
<div
className="database-key-confirm"
role="dialog"
aria-modal="true"
aria-labelledby="database-key-confirm-title"
onMouseDown={(event) => event.stopPropagation()}
>
<h2 id="database-key-confirm-title"></h2>
<p>
WechatExplorer
</p>
<div>
<button type="button" onClick={() => setConfirming(false)}>
</button>
<button
type="button"
className="danger"
onClick={() => {
setConfirming(false)
onClear()
}}
>
</button>
</div>
</div>
</div>
)}
</>
)
}
@@ -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 (
<details className="database-key-diagnostics">
<summary></summary>
<dl>
<div>
<dt></dt>
<dd>{state.saved ? '是' : '否'}</dd>
</div>
<div>
<dt></dt>
<dd>{state.validation?.success ? '是' : '否'}</dd>
</div>
<div>
<dt></dt>
<dd>{isDatabaseKeyFormatValid(input) ? '是' : '否'}</dd>
</div>
<div>
<dt> wxid</dt>
<dd>{wxid || state.validation?.wxid || '未识别'}</dd>
</div>
<div>
<dt></dt>
<dd title={accountRoot || state.validation?.accountRoot}>
{accountRoot || state.validation?.accountRoot || '未识别'}
</dd>
</div>
<div>
<dt></dt>
<dd>{formatValidationTime(state.lastValidatedAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{state.errorCode || '无'}</dd>
</div>
<div>
<dt></dt>
<dd>{state.environment?.platform || '未知'}</dd>
</div>
<div>
<dt></dt>
<dd>{state.environment?.autoDetectSupported ? '支持' : '不支持'}</dd>
</div>
</dl>
<button type="button" onClick={onCopy}>
</button>
</details>
)
}
@@ -0,0 +1,93 @@
import { useState } from 'react'
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
return visible ? (
<svg viewBox="0 0 24 24" aria-hidden>
<path d="M3 12s3.5-6 9-6 9 6 9 6-3.5 6-9 6-9-6-9-6Z" />
<circle cx="12" cy="12" r="2.6" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden>
<path d="m4 4 16 16M10.6 6.2A9.6 9.6 0 0 1 12 6c5.5 0 9 6 9 6a15 15 0 0 1-2.1 2.8M14.8 14.8A4 4 0 0 1 9.2 9.2M6.1 7.1C4.2 8.7 3 12 3 12s3.5 6 9 6c1 0 2-.2 2.9-.6" />
</svg>
)
}
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 (
<section id="database-key-editor" className="settings-card database-key-editor">
<label htmlFor="wechat-db-key">WeChat DB Key</label>
<div className="database-key-input-row">
<input
id="wechat-db-key"
type={visible ? 'text' : 'password'}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
placeholder="输入 64 位十六进制数据库密钥"
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => setVisible((current) => !current)}
title={visible ? '隐藏密钥' : '显示密钥'}
disabled={disabled}
>
<EyeIcon visible={visible} />
</button>
<button type="button" onClick={onPaste} title="从剪贴板粘贴" disabled={disabled}>
<svg viewBox="0 0 24 24" aria-hidden>
<path d="M9 5h6M9 3h6v4H9zM7 5H5v16h14V5h-2" />
</svg>
</button>
<button
type="button"
onClick={() => onChange('')}
title="清空当前输入"
disabled={disabled || !value}
>
<svg viewBox="0 0 24 24" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</div>
<p></p>
<div className="database-key-actions">
<button
type="button"
className="database-key-secondary"
onClick={onValidate}
disabled={disabled || !value}
>
</button>
<button
type="button"
className="database-key-primary"
onClick={onSave}
disabled={disabled || !canSave}
>
</button>
</div>
</section>
)
}
@@ -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 (
<section className="settings-card database-key-status-card">
<dl>
<div>
<dt></dt>
<dd>{state.saved ? '已配置' : '未配置'}</dd>
</div>
<div>
<dt></dt>
<dd>{state.encryptionAvailable ? '系统安全存储' : '系统安全存储不可用'}</dd>
</div>
<div>
<dt></dt>
<dd>{selfInfo?.nickname || '尚未识别'}</dd>
</div>
<div>
<dt>wxid</dt>
<dd className="database-key-mono">{selfInfo?.wxid || state.validation?.wxid || '—'}</dd>
</div>
<div>
<dt></dt>
<dd>{formatValidationTime(state.lastValidatedAt)}</dd>
</div>
<div>
<dt></dt>
<dd className={dbReady ? 'database-key-success' : 'database-key-muted'}>
{dbReady ? '连接正常' : '尚未连接'}
</dd>
</div>
</dl>
<button
type="button"
className="database-key-secondary"
onClick={onValidate}
disabled={disabled}
>
</button>
</section>
)
}
@@ -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 (
<div className="database-key-feedback checking">
<i />
</div>
)
}
if (['invalid', 'save-error', 'clear-error'].includes(state.status)) {
return <div className="database-key-feedback error">{state.error || '密钥验证失败'}</div>
}
if (!state.validation?.success) return null
return (
<div className="database-key-feedback success">
<strong></strong>
<span>{state.validation.wxid || '已识别'}</span>
<span>{state.validation.contacts?.available ? '可用' : '不可用'}</span>
<span>{state.validation.messages?.available ? '可用' : '不可用'}</span>
<span title={state.validation.accountRoot}>
{state.validation.accountRoot || '已识别'}
</span>
</div>
)
}
@@ -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 }
}
}
@@ -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<void>
validateKey: () => Promise<void>
saveKey: () => Promise<void>
autoDetectKey: () => Promise<void>
clearSavedKey: () => Promise<void>
copyDiagnostics: () => Promise<void>
refreshEnvironment: () => Promise<void>
}
@@ -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<void> => {
const environment = await window.api.getDatabaseKeyEnvironment()
dispatch({ type: 'ENVIRONMENT_LOADED', environment })
}, [])
const refreshStorage = useCallback(async (): Promise<void> => {
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<void> => {
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<boolean> => {
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<void> => {
await runValidation(dbKey)
}, [dbKey, runValidation])
const saveKey = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<DatabaseKeyController['pageStatus']>(() => {
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
}
}
@@ -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')
}
@@ -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 (
<div className="settings-page database-key-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p></p>
</div>
<span className={`settings-status-badge database-key-badge ${controller.pageStatus}`}>
{STATUS_LABELS[controller.pageStatus]}
</span>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content database-key-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>
<DatabaseKeyStatus
state={controller.state}
dbReady={dbReady}
selfInfo={selfInfo}
disabled={controller.isBusy || !dbKey}
onValidate={() => void controller.validateKey()}
/>
<h2 className="settings-section-heading"></h2>
<DatabaseKeyEditor
value={dbKey}
disabled={controller.isBusy}
canSave={controller.canSave}
onChange={controller.editKey}
onPaste={() => void controller.pasteKey()}
onValidate={() => void controller.validateKey()}
onSave={() => void controller.saveKey()}
/>
<DatabaseKeyValidation state={controller.state} />
<h2 className="settings-section-heading"></h2>
<DatabaseKeyAutoDetect
state={controller.state}
disabled={controller.isBusy}
onDetect={() => void controller.autoDetectKey()}
onRefresh={() => void controller.refreshEnvironment()}
/>
<h2 className="settings-section-heading"></h2>
<div className="database-key-security-info">
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
<DatabaseKeyDiagnostics
state={controller.state}
input={dbKey}
wxid={selfInfo?.wxid}
accountRoot={selfInfo?.accountRoot}
onCopy={() => void controller.copyDiagnostics()}
/>
<DatabaseKeyDangerZone
disabled={controller.isBusy || !controller.state.saved}
onClear={() => void controller.clearSavedKey()}
onReplace={scrollToEditor}
/>
</div>
</div>
</div>
)
}
+34
View File
@@ -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
}