feat: 提交

This commit is contained in:
Wxw-Gu
2026-07-27 20:34:57 +08:00
parent 95e517eca5
commit f626d89a1a
7 changed files with 154 additions and 42 deletions
+58 -34
View File
@@ -17,7 +17,7 @@ import { extname } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { WechatDb } from './wechat-db' import { WechatDb } from './wechat-db'
import { bootstrapWcdbNative } from './wcdb4-client' import { bootstrapWcdbNative, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service' import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service' import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser' import { parseMessageContent } from './message-parser'
@@ -94,6 +94,51 @@ const keyServiceMac = new KeyServiceMac()
const keyServiceWin = new KeyServiceWin() const keyServiceWin = new KeyServiceWin()
let tray: Tray | null = null let tray: Tray | null = null
let recallArchiveMonitor: RecallArchiveMonitor | 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 packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
@@ -316,36 +361,7 @@ app.whenReady().then(async () => {
} }
chat.setChatDb(nextWechatDb) chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client() const wcdb4Client = nextWechatDb.getWcdb4Client()
configureRecallArchive(resolvedRoot) configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
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)
voiceService = new VoiceService(wcdb4Client) voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client) stickerService = new StickerService(wcdb4Client)
videoAssetService = new VideoAssetService(wcdb4Client) videoAssetService = new VideoAssetService(wcdb4Client)
@@ -796,13 +812,21 @@ app.whenReady().then(async () => {
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40' const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
const aesKey = patch.imageAesKey ?? current.aesKey ?? '' const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
const nextSettings = saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
if (includesImageKey) { if (includesImageKey) {
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey }) if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
else imageKeyConfigService.clear() else imageKeyConfigService.clear()
imageDecryptService = null 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 { return {
settings: imageKeyConfigService.getLegacySettingsView(), settings: imageKeyConfigService.getLegacySettingsView(),
+2 -3
View File
@@ -2,7 +2,6 @@ import { app } from 'electron'
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path' import path from 'path'
import os from 'os' import os from 'os'
import { discoverWindowsDbRoots } from '../windows-db-root-discovery'
/** /**
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到 * 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
@@ -29,6 +28,7 @@ export interface AppSettings {
imageXorKey: string imageXorKey: string
imageAesKey: string imageAesKey: string
imageKeyFallbackDisabled: boolean imageKeyFallbackDisabled: boolean
recallProtectionEnabled: boolean
autoLogin: boolean autoLogin: boolean
autoLoginPreferenceSet: boolean autoLoginPreferenceSet: boolean
} }
@@ -55,8 +55,6 @@ function getDefaultDbRootCandidates(home: string): string[] {
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files') path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
] ]
candidates.push(...discoverWindowsDbRoots())
return unique(candidates) return unique(candidates)
} }
@@ -106,6 +104,7 @@ const DEFAULT_SETTINGS: AppSettings = {
imageXorKey: '', imageXorKey: '',
imageAesKey: '', imageAesKey: '',
imageKeyFallbackDisabled: false, imageKeyFallbackDisabled: false,
recallProtectionEnabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes( autoLogin: ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '') String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim() .trim()
-2
View File
@@ -1,7 +1,6 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path' import path from 'path'
import os from 'os' import os from 'os'
import { discoverWindowsDbRoots } from './windows-db-root-discovery'
import crypto from 'crypto' import crypto from 'crypto'
import { createRequire } from 'module' import { createRequire } from 'module'
import { createConnection, Socket } from 'net' import { createConnection, Socket } from 'net'
@@ -319,7 +318,6 @@ export class Wcdb4Client {
path.join(home, 'Documents', 'xwechat_files'), path.join(home, 'Documents', 'xwechat_files'),
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files') path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
] ]
candidates.push(...discoverWindowsDbRoots())
return Array.from(new Set(candidates)) return Array.from(new Set(candidates))
} }
return [ return [
+4
View File
@@ -208,6 +208,7 @@ declare global {
apiHost: string apiHost: string
apiPort: number apiPort: number
imageKeyRoot: string imageKeyRoot: string
recallProtectionEnabled: boolean
autoLogin: boolean autoLogin: boolean
autoLoginPreferenceSet: boolean autoLoginPreferenceSet: boolean
imageXorKey: string imageXorKey: string
@@ -234,6 +235,7 @@ declare global {
apiHost: string apiHost: string
apiPort: number apiPort: number
imageKeyRoot: string imageKeyRoot: string
recallProtectionEnabled: boolean
autoLogin: boolean autoLogin: boolean
autoLoginPreferenceSet: boolean autoLoginPreferenceSet: boolean
imageXorKey: string imageXorKey: string
@@ -248,6 +250,7 @@ declare global {
apiHost: string apiHost: string
apiPort: number apiPort: number
imageKeyRoot: string imageKeyRoot: string
recallProtectionEnabled: boolean
autoLogin: boolean autoLogin: boolean
autoLoginPreferenceSet: boolean autoLoginPreferenceSet: boolean
imageXorKey: string imageXorKey: string
@@ -260,6 +263,7 @@ declare global {
apiHost: string apiHost: string
apiPort: number apiPort: number
imageKeyRoot: string imageKeyRoot: string
recallProtectionEnabled: boolean
autoLogin: boolean autoLogin: boolean
autoLoginPreferenceSet: boolean autoLoginPreferenceSet: boolean
imageXorKey: string imageXorKey: string
+2 -2
View File
@@ -317,7 +317,7 @@ function App(): React.ReactElement {
usernames.length ? 55 : 90 usernames.length ? 55 : 90
) )
let loadedCount = 0 let loadedCount = 0
const chunkSize = 32 const chunkSize = 128
for (let index = 0; index < usernames.length; index += chunkSize) { for (let index = 0; index < usernames.length; index += chunkSize) {
if (runId !== contactAvatarHydrationRunRef.current) return if (runId !== contactAvatarHydrationRunRef.current) return
const chunk = usernames.slice(index, index + chunkSize) const chunk = usernames.slice(index, index + chunkSize)
@@ -348,7 +348,7 @@ function App(): React.ReactElement {
} catch (error) { } catch (error) {
console.warn('[Contacts] avatar hydrate failed:', 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) if (usernames.length) onProgress?.('头像加载完成', 90)
} }
+58
View File
@@ -6239,6 +6239,15 @@ body {
.settings-page-header { .settings-page-header {
padding: 20px 24px; 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 { .settings-account-overview {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 18px; gap: 18px;
@@ -7788,6 +7797,55 @@ body {
height: 18px; height: 18px;
accent-color: var(--wxex-brand); 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 { .api-upload-test-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -27,11 +27,14 @@ export function AccountDatabasePage({
}): React.ReactElement { }): React.ReactElement {
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice }) const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
const [autoLogin, setAutoLogin] = useState(false) const [autoLogin, setAutoLogin] = useState(false)
const [recallProtectionEnabled, setRecallProtectionEnabled] = useState(false)
useEffect(() => { useEffect(() => {
let active = true let active = true
void window.api.getSettings().then((result) => { void window.api.getSettings().then((result) => {
if (active) setAutoLogin(result.settings.autoLogin) if (!active) return
setAutoLogin(result.settings.autoLogin)
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
}) })
return () => { return () => {
active = false active = false
@@ -46,6 +49,12 @@ export function AccountDatabasePage({
setAutoLogin(result.settings.autoLogin) setAutoLogin(result.settings.autoLogin)
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接') onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
} }
const changeRecallProtection = async (checked: boolean): Promise<void> => {
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
}
return ( return (
<div className="settings-page"> <div className="settings-page">
<header className="settings-page-header"> <header className="settings-page-header">
@@ -93,6 +102,26 @@ export function AccountDatabasePage({
/> />
</label> </label>
</section> </section>
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-recall-card">
<div className="settings-recall-grid">
<label className="settings-recall-option">
<span>
<b></b>
<small>便</small>
</span>
<input
type="checkbox"
checked={recallProtectionEnabled}
onChange={(event) => void changeRecallProtection(event.target.checked)}
/>
</label>
<aside className="settings-recall-notice">
<strong></strong>
<span></span>
</aside>
</div>
</section>
</div> </div>
</div> </div>
</div> </div>