mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
实现数据库密钥设置页
This commit is contained in:
@@ -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<StoredKeyResult> {
|
||||
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<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)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-1
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
export function testConnection(key: string, accountRoot?: string): DatabaseKeyValidationResult {
|
||||
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) {
|
||||
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<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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+15
-12
@@ -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<SaveGeneratedReportResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
autoGetDbKey: () => Promise<{
|
||||
getSavedDbKey: () => Promise<DatabaseKeyStorageResult>
|
||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||
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<DatabaseKeyStorageResult>
|
||||
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<DatabaseKeyValidationResult>
|
||||
reopenWithRoot: (accountRoot: string) => Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
|
||||
@@ -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),
|
||||
|
||||
+11
-11
@@ -188,9 +188,13 @@ function App(): React.ReactElement {
|
||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||
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 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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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' ? (
|
||||
<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]} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user