diff --git a/src/main/database-key-store.ts b/src/main/database-key-store.ts new file mode 100644 index 0000000..904d973 --- /dev/null +++ b/src/main/database-key-store.ts @@ -0,0 +1,63 @@ +import { app, safeStorage } from 'electron' +import fs from 'fs-extra' +import path from 'path' + +export interface StoredKeyResult { + success: boolean + key?: string + error?: string +} + +const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '') + +export const isValidDatabaseKey = (value: string): boolean => + /^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value)) + +export class DatabaseKeyStore { + private get filePath(): string { + return path.join(app.getPath('userData'), 'wechat-db-key.bin') + } + + async load(): Promise { + try { + if (!(await fs.pathExists(this.filePath))) return { success: true } + if (!safeStorage.isEncryptionAvailable()) { + return { success: false, error: '系统安全存储不可用' } + } + const encrypted = await fs.readFile(this.filePath) + const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted)) + if (!isValidDatabaseKey(key)) return { success: false, error: '已保存的密钥格式无效' } + return { success: true, key } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async save(rawKey: string): Promise { + const key = normalizeDatabaseKey(rawKey) + if (!isValidDatabaseKey(key)) { + return { success: false, error: '密钥必须是 64 位十六进制字符' } + } + if (!safeStorage.isEncryptionAvailable()) { + return { success: false, error: '系统安全存储不可用' } + } + + 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 } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async clear(): Promise<{ success: boolean; error?: string }> { + try { + await fs.remove(this.filePath) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index d67b8bc..205bdf1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,11 +13,15 @@ import { import { ImageDecryptService } from './image-decrypt-service' import { exportGroupReport } from './group-report-service' import { GroupReportExportRequest } from '../shared/group-report' +import { DatabaseKeyStore } from './database-key-store' +import { KeyServiceMac } from './key-service-mac' let wechatDb: WechatDb | null = null let voiceService: VoiceService | null = null let imageDecryptService: ImageDecryptService | null = null let stickerService: StickerService | null = null +const databaseKeyStore = new DatabaseKeyStore() +const keyServiceMac = new KeyServiceMac() const BUILD_MARK = 'wechat4-open-account-continues-after-init-1000' // WechatExplorer's WCDB native library runs InitProtection before wcdb_init. @@ -119,6 +123,29 @@ app.whenReady().then(() => { } }) + ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load()) + + ipcMain.handle('key:pasteAndSaveDbKey', async () => { + const clipboardKey = clipboard.readText().trim() + return databaseKeyStore.save(clipboardKey) + }) + + ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear()) + + ipcMain.handle('key:autoGetDbKey', async (event) => { + const result = await keyServiceMac.autoGetDbKey((message) => { + if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message }) + }) + if (!result.success || !result.key) return result + + const saved = await databaseKeyStore.save(result.key) + return { + ...result, + saved: saved.success, + warning: saved.success ? undefined : saved.error + } + }) + ipcMain.handle('db:getContacts', (_, filter?: string) => { if (!wechatDb) return [] diff --git a/src/main/key-service-mac.ts b/src/main/key-service-mac.ts new file mode 100644 index 0000000..3feacc2 --- /dev/null +++ b/src/main/key-service-mac.ts @@ -0,0 +1,160 @@ +import { app } from 'electron' +import { execFile } from 'child_process' +import fs from 'fs-extra' +import path from 'path' +import { promisify } from 'util' +import { isValidDatabaseKey } from './database-key-store' + +const execFileAsync = promisify(execFile) + +export interface DatabaseKeyResult { + success: boolean + key?: string + error?: string + code?: string +} + +export class KeyServiceMac { + private getHelperPath(): string { + const candidates = app.isPackaged + ? [ + path.join(process.resourcesPath, 'resources', 'xkey_helper'), + path.join(process.resourcesPath, 'xkey_helper') + ] + : [ + path.join(process.cwd(), 'resources', 'xkey_helper'), + path.join(app.getAppPath(), 'resources', 'xkey_helper') + ] + const helperPath = candidates.find((candidate) => fs.existsSync(candidate)) + if (!helperPath) throw new Error('找不到 xkey_helper') + return helperPath + } + + private async isSipEnabled(): Promise { + try { + const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status']) + return stdout.toLowerCase().includes('enabled') + } catch { + return false + } + } + + private async getWeChatPid(): Promise { + const commands: [string, string[]][] = [ + ['/usr/bin/pgrep', ['-x', 'WeChat']], + ['/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']] + ] + for (const [command, args] of commands) { + try { + const { stdout } = await execFileAsync(command, args) + const pids = stdout + .split(/\r?\n/) + .map((value) => Number.parseInt(value.trim(), 10)) + .filter((value) => Number.isFinite(value) && value > 0) + if (pids.length) return Math.max(...pids) + } catch { + // Try the next process lookup strategy. + } + } + throw new Error('未找到微信主进程,请先启动并登录微信') + } + + private parseHelperOutput(output: string): DatabaseKeyResult { + const payloads: Record[] = [] + for (const match of output.matchAll(/\{[^{}]*\}/g)) { + try { + payloads.push(JSON.parse(match[0]) as Record) + } catch { + // Ignore helper progress that is not JSON. + } + } + const payload = payloads.find((item) => item.success === true && typeof item.key === 'string') + const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : '' + if (!isValidDatabaseKey(rawKey)) { + const errorPayload = payloads.find((item) => typeof item.result === 'string') + const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : '' + const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i) + const code = parsedError?.[1]?.toUpperCase() + const detail = parsedError?.[2]?.trim() || '' + if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) { + return { + success: false, + code, + error: + '内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' + + '建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' + + '请不要连续重试,以免触发微信安全模式或系统内存保护。' + } + } + if (code === 'SCAN_FAILED') { + return { + success: false, + code, + error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}` + } + } + return { + success: false, + code, + error: rawError || '密钥工具未返回有效的 64 位密钥' + } + } + return { success: true, key: rawKey } + } + + async autoGetDbKey( + onStatus?: (message: string) => void, + timeoutMs = 60_000 + ): Promise { + if (process.platform !== 'darwin') { + return { success: false, error: '自动获取密钥目前仅支持 macOS' } + } + if (await this.isSipEnabled()) { + return { + success: false, + error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。' + } + } + + try { + onStatus?.('正在查找微信进程...') + const pid = await this.getWeChatPid() + const helperPath = this.getHelperPath() + const waitMs = Math.max(30_000, timeoutMs) + const timeoutSeconds = Math.ceil(waitMs / 1000) + 30 + onStatus?.('正在请求管理员授权...') + const scriptLines = [ + `set helperPath to ${JSON.stringify(helperPath)}`, + `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + `set timeoutSec to ${timeoutSeconds}`, + 'try', + 'with timeout of timeoutSec seconds', + 'set outText to do shell script cmd with administrator privileges', + 'end timeout', + 'return "OK::" & outText', + 'on error errMsg number errNum', + 'return "ERR::" & errNum & "::" & errMsg', + 'end try' + ] + onStatus?.('授权后请保持微信已登录并活动...') + const { stdout } = await execFileAsync( + '/usr/bin/osascript', + scriptLines.flatMap((line) => ['-e', line]), + { timeout: waitMs + 20_000 } + ) + const output = String(stdout).trim() + if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' } + if (output.startsWith('ERR::')) { + return { + success: false, + error: output.split('::').slice(2).join('::') || '密钥工具执行失败' + } + } + const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output) + onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败') + return result + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d5f875c..79dd4f3 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -61,6 +61,18 @@ declare global { ) => Promise<{ success: boolean; data?: string; error?: string }> exportGroupReport: (request: GroupReportExportRequest) => Promise revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> + getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> + autoGetDbKey: () => Promise<{ + success: boolean + key?: string + error?: string + code?: string + saved?: boolean + warning?: string + }> + pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> + clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> + onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index a452a75..13cba50 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -23,7 +23,17 @@ const api = { getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), exportGroupReport: (request: GroupReportExportRequest) => ipcRenderer.invoke('report:export', request), - revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath) + revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), + getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), + autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), + pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'), + clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'), + onDbKeyStatus: (callback: (payload: { message: string }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void => + callback(payload) + ipcRenderer.on('key:dbKeyStatus', listener) + return () => ipcRenderer.removeListener('key:dbKeyStatus', listener) + } } if (process.contextIsolated) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 073dd32..befbb1b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,6 +3,8 @@ import { Sidebar } from './components/Sidebar' import ChatWindow from './components/ChatWindow' import { Contact, Message } from '../../shared/types' +const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' + function App(): React.ReactElement { const [isAuthenticated, setIsAuthenticated] = useState(false) const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '') @@ -12,6 +14,32 @@ function App(): React.ReactElement { const [filteredContacts, setFilteredContacts] = useState([]) const [dateRange, setDateRange] = useState('today') // 默认为今天 const [contentFilter, setContentFilter] = useState('') + const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) + const [dbKeyStatus, setDbKeyStatus] = useState('') + const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal') + const [showDbKey, setShowDbKey] = useState(false) + const [showMacKeyFaq, setShowMacKeyFaq] = useState(false) + + React.useEffect(() => { + let active = true + void window.api.getSavedDbKey().then((result) => { + if (!active) return + if (result.success && result.key) { + setDbKey(result.key) + setDbKeyStatus('已加载安全保存的密钥') + setDbKeyStatusKind('success') + } + }) + const unsubscribe = window.api.onDbKeyStatus(({ message }) => { + if (!active) return + setDbKeyStatus(message) + setDbKeyStatusKind('normal') + }) + return () => { + active = false + unsubscribe() + } + }, []) // useEffect(() => { // if (import.meta.env.VITE_DB_KEY) { @@ -38,6 +66,55 @@ function App(): React.ReactElement { } } + const handleAutoGetDbKey = async (): Promise => { + if (isFetchingDbKey) return + setIsFetchingDbKey(true) + setDbKeyStatus('正在准备获取密钥...') + setDbKeyStatusKind('normal') + setShowMacKeyFaq(false) + try { + const result = await window.api.autoGetDbKey() + if (!result.success || !result.key) { + setShowMacKeyFaq(result.code === 'SCAN_FAILED') + throw new Error(result.error || '获取密钥失败') + } + setDbKey(result.key) + setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取') + setDbKeyStatusKind(result.saved ? 'success' : 'normal') + } catch (error) { + setDbKeyStatus(error instanceof Error ? error.message : String(error)) + setDbKeyStatusKind('error') + } finally { + setIsFetchingDbKey(false) + } + } + + const handlePasteAndSaveDbKey = async (): Promise => { + setShowMacKeyFaq(false) + const result = await window.api.pasteAndSaveDbKey() + if (result.success && result.key) { + setDbKey(result.key) + setDbKeyStatus('已从剪贴板粘贴并安全保存') + setDbKeyStatusKind('success') + } else { + setDbKeyStatus(result.error || '粘贴并保存失败') + setDbKeyStatusKind('error') + } + } + + const handleClearSavedDbKey = async (): Promise => { + setShowMacKeyFaq(false) + const result = await window.api.clearSavedDbKey() + if (!result.success) { + setDbKeyStatus(result.error || '清除密钥失败') + setDbKeyStatusKind('error') + return + } + setDbKey('') + setDbKeyStatus('已清除保存的密钥') + setDbKeyStatusKind('normal') + } + const loadContacts = async (): Promise => { const list = await window.api.getContacts() setContacts(list) @@ -146,16 +223,52 @@ function App(): React.ReactElement {

Enter WeChat DB Key

- setDbKey(e.target.value)} - placeholder="Key (e.g. 0x...)" - /> +
+ setDbKey(e.target.value)} + placeholder="Key (e.g. 0x...)" + /> + +
+ + + + {dbKeyStatus && ( +
{dbKeyStatus}
+ )} + {showMacKeyFaq && ( + + 查看 macOS 获取密钥排障指引 + + )}
) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index fd5ab80..74def3c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -654,7 +654,7 @@ body { background-color: #fff; padding: 20px; border-radius: 8px; - width: 300px; + width: 360px; text-align: center; } @@ -666,6 +666,27 @@ body { border-radius: 4px; } +.login-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.login-input-wrapper .login-input { + padding-right: 40px; + margin: 10px 0 10px 0; +} + +.login-input-toggle { + position: absolute; + right: 8px; + background: none; + border: none; + cursor: pointer; + padding: 4px 8px; + font-size: 18px; +} + .login-btn { background-color: #07c160; color: #fff; @@ -673,12 +694,79 @@ body { padding: 8px 20px; border-radius: 4px; cursor: pointer; + width: 100%; + margin-top: 8px; } .login-btn:hover { background-color: #06ad56; } +.login-btn:disabled { + cursor: wait; + opacity: 0.65; +} + +.login-btn-secondary { + border: 1px solid #cfd5d8; + background: #fff; + color: #30383d; +} + +.login-btn-secondary:hover { + border-color: #07c160; + background: #f2fbf6; + color: #078f49; +} + +.login-clear-btn { + margin-top: 10px; + border: 0; + background: transparent; + color: #8a949a; + cursor: pointer; + font-size: 12px; +} + +.login-clear-btn:hover { + color: #d33b3b; +} + +.login-key-status { + margin-top: 10px; + padding: 8px 10px; + border-radius: 6px; + background: #f3f5f6; + color: #59636a; + font-size: 12px; + line-height: 1.45; + text-align: left; + white-space: pre-wrap; + word-break: break-word; +} + +.login-key-status.success { + background: #eefaf3; + color: #078f49; +} + +.login-key-status.error { + background: #fff1f0; + color: #c73737; +} + +.login-key-help-link { + display: inline-block; + margin-top: 9px; + color: #1677ff; + font-size: 12px; + text-decoration: none; +} + +.login-key-help-link:hover { + text-decoration: underline; +} + .modal-overlay { position: fixed; top: 0;