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 { 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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Vendored
+15
-12
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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