diff --git a/src/main/index.ts b/src/main/index.ts index ec020dc..63fbcd8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,7 @@ import { extname } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { WechatDb } from './wechat-db' -import { bootstrapWcdbNative } from './wcdb4-client' +import { bootstrapWcdbNative, Wcdb4Client } from './wcdb4-client' import { VoiceService } from './voice-service' import { StickerService } from './sticker-service' import { parseMessageContent } from './message-parser' @@ -94,6 +94,51 @@ const keyServiceMac = new KeyServiceMac() const keyServiceWin = new KeyServiceWin() let tray: Tray | null = null let recallArchiveMonitor: RecallArchiveMonitor | null = null +let recallProtectionGeneration = 0 + +function configureRecallProtection( + wcdb4Client: Wcdb4Client, + accountRoot: string, + enabled: boolean +): void { + recallProtectionGeneration += 1 + const generation = recallProtectionGeneration + recallArchiveMonitor?.stop() + recallArchiveMonitor = null + configureRecallArchive(enabled ? accountRoot : '') + if (!enabled) return + + const monitor = new RecallArchiveMonitor( + () => + wcdb4Client.getSessions().map((session) => ({ + md5: wcdb4Client.md5(session.username), + m_nsUsrName: session.username, + type: session.username.endsWith('@chatroom') ? 'group' : 'user', + activityKey: [ + session.raw['last_timestamp'], + session.raw['sort_timestamp'], + session.raw['last_msg_locald_id'], + session.raw['last_msg_type'], + session.raw['summary'] + ].join(':') + })), + (sessionMd5) => + chat.listMessages(sessionMd5, Math.floor(Date.now() / 1000) - 10 * 60, undefined, { + limit: 500 + }) + ) + recallArchiveMonitor = monitor + monitor.seedAll() + setTimeout(() => { + if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return + const result = wcdb4Client.installRecallJournal( + wcdb4Client.getSessions().map((session) => session.username) + ) + console.log( + `[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}` + ) + }, 0) +} const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png') const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon @@ -316,36 +361,7 @@ app.whenReady().then(async () => { } chat.setChatDb(nextWechatDb) const wcdb4Client = nextWechatDb.getWcdb4Client() - configureRecallArchive(resolvedRoot) - recallArchiveMonitor?.stop() - recallArchiveMonitor = new RecallArchiveMonitor( - () => - wcdb4Client.getSessions().map((session) => ({ - md5: wcdb4Client.md5(session.username), - m_nsUsrName: session.username, - type: session.username.endsWith('@chatroom') ? 'group' : 'user', - activityKey: [ - session.raw['last_timestamp'], - session.raw['sort_timestamp'], - session.raw['last_msg_locald_id'], - session.raw['last_msg_type'], - session.raw['summary'] - ].join(':') - })), - (sessionMd5) => - chat.listMessages(sessionMd5, Math.floor(Date.now() / 1000) - 10 * 60, undefined, { - limit: 500 - }) - ) - recallArchiveMonitor.seedAll() - setTimeout(() => { - const result = wcdb4Client.installRecallJournal( - wcdb4Client.getSessions().map((session) => session.username) - ) - console.log( - `[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}` - ) - }, 0) + configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled) voiceService = new VoiceService(wcdb4Client) stickerService = new StickerService(wcdb4Client) videoAssetService = new VideoAssetService(wcdb4Client) @@ -796,13 +812,21 @@ app.whenReady().then(async () => { const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40' const aesKey = patch.imageAesKey ?? current.aesKey ?? '' const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch + const nextSettings = saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' }) if (includesImageKey) { - saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' }) if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey }) else imageKeyConfigService.clear() imageDecryptService = null - } else { - saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' }) + } + if ('recallProtectionEnabled' in patch && chat.isReady()) { + const currentDb = chat.getChatDb() + if (currentDb) { + configureRecallProtection( + currentDb.getWcdb4Client(), + chat.getCurrentAccountRoot(), + nextSettings.recallProtectionEnabled + ) + } } return { settings: imageKeyConfigService.getLegacySettingsView(), diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index fc135c5..91cab34 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -2,7 +2,6 @@ import { app } from 'electron' import fs from 'fs-extra' import path from 'path' import os from 'os' -import { discoverWindowsDbRoots } from '../windows-db-root-discovery' /** * 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到 @@ -29,6 +28,7 @@ export interface AppSettings { imageXorKey: string imageAesKey: string imageKeyFallbackDisabled: boolean + recallProtectionEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean } @@ -55,8 +55,6 @@ function getDefaultDbRootCandidates(home: string): string[] { path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files') ] - candidates.push(...discoverWindowsDbRoots()) - return unique(candidates) } @@ -106,6 +104,7 @@ const DEFAULT_SETTINGS: AppSettings = { imageXorKey: '', imageAesKey: '', imageKeyFallbackDisabled: false, + recallProtectionEnabled: false, autoLogin: ['1', 'true', 'yes', 'on'].includes( String(import.meta.env.VITE_AUTO_LOGIN || '') .trim() diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 0b30477..ad75e92 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -1,7 +1,6 @@ import fs from 'fs-extra' import path from 'path' import os from 'os' -import { discoverWindowsDbRoots } from './windows-db-root-discovery' import crypto from 'crypto' import { createRequire } from 'module' import { createConnection, Socket } from 'net' @@ -319,7 +318,6 @@ export class Wcdb4Client { path.join(home, 'Documents', 'xwechat_files'), path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files') ] - candidates.push(...discoverWindowsDbRoots()) return Array.from(new Set(candidates)) } return [ diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index ef4aa19..a3e5f1e 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -208,6 +208,7 @@ declare global { apiHost: string apiPort: number imageKeyRoot: string + recallProtectionEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean imageXorKey: string @@ -234,6 +235,7 @@ declare global { apiHost: string apiPort: number imageKeyRoot: string + recallProtectionEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean imageXorKey: string @@ -248,6 +250,7 @@ declare global { apiHost: string apiPort: number imageKeyRoot: string + recallProtectionEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean imageXorKey: string @@ -260,6 +263,7 @@ declare global { apiHost: string apiPort: number imageKeyRoot: string + recallProtectionEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean imageXorKey: string diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0540531..3046bf6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -317,7 +317,7 @@ function App(): React.ReactElement { usernames.length ? 55 : 90 ) let loadedCount = 0 - const chunkSize = 32 + const chunkSize = 128 for (let index = 0; index < usernames.length; index += chunkSize) { if (runId !== contactAvatarHydrationRunRef.current) return const chunk = usernames.slice(index, index + chunkSize) @@ -348,7 +348,7 @@ function App(): React.ReactElement { } catch (error) { console.warn('[Contacts] avatar hydrate failed:', error) } - await new Promise((resolve) => window.setTimeout(resolve, 50)) + await new Promise((resolve) => window.setTimeout(resolve, 20)) } if (usernames.length) onProgress?.('头像加载完成', 90) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 93196ba..bfde128 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -6239,6 +6239,15 @@ body { .settings-page-header { padding: 20px 24px; } + .settings-recall-grid { + grid-template-columns: 1fr; + } + .settings-recall-notice { + padding-top: 10px; + padding-left: 0; + border-top: 2px solid #e5b45f; + border-left: 0; + } .settings-account-overview { grid-template-columns: minmax(0, 1fr) auto; gap: 18px; @@ -7788,6 +7797,55 @@ body { height: 18px; accent-color: var(--wxex-brand); } +.settings-recall-card { + padding: 18px 20px; +} +.settings-recall-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr); + gap: 18px; + align-items: center; +} +.settings-recall-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-width: 0; + cursor: pointer; +} +.settings-recall-option span { + display: grid; + gap: 4px; +} +.settings-recall-option b { + color: var(--wxex-text-primary); + font: 700 13px/19px var(--wxex-font); +} +.settings-recall-option small { + color: var(--wxex-text-secondary); + font: 12px/18px var(--wxex-font); +} +.settings-recall-option input { + width: 18px; + height: 18px; + flex: 0 0 auto; + accent-color: var(--wxex-brand); +} +.settings-recall-notice { + display: grid; + gap: 3px; + padding-left: 14px; + border-left: 2px solid #e5b45f; + color: #765326; + font: 12px/18px var(--wxex-font); +} +.settings-recall-notice strong { + font-weight: 700; +} +.settings-recall-notice span { + color: #8a6a3e; +} .api-upload-test-row { display: flex; align-items: center; diff --git a/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx b/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx index a2a6b39..bc9db08 100644 --- a/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx +++ b/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx @@ -27,11 +27,14 @@ export function AccountDatabasePage({ }): React.ReactElement { const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice }) const [autoLogin, setAutoLogin] = useState(false) + const [recallProtectionEnabled, setRecallProtectionEnabled] = useState(false) useEffect(() => { let active = true void window.api.getSettings().then((result) => { - if (active) setAutoLogin(result.settings.autoLogin) + if (!active) return + setAutoLogin(result.settings.autoLogin) + setRecallProtectionEnabled(result.settings.recallProtectionEnabled) }) return () => { active = false @@ -46,6 +49,12 @@ export function AccountDatabasePage({ setAutoLogin(result.settings.autoLogin) onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接') } + + const changeRecallProtection = async (checked: boolean): Promise => { + const result = await window.api.setSettings({ recallProtectionEnabled: checked }) + setRecallProtectionEnabled(result.settings.recallProtectionEnabled) + onNotice(checked ? '已开启防撤回' : '已关闭防撤回') + } return (
@@ -93,6 +102,26 @@ export function AccountDatabasePage({ /> +

数据读取

+
+
+ + +
+