Merge pull request #337 from xunchahaha/dev

Dev
This commit is contained in:
xuncha
2026-02-28 21:19:21 +08:00
committed by GitHub
5 changed files with 98 additions and 41 deletions

View File

@@ -3438,9 +3438,10 @@ class ChatService {
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId) const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' } if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
// 4. 获取解密密钥 // 4. 获取解密密钥(优先使用当前 wxid 对应的密钥)
const xorKeyRaw = this.configService.get('imageXorKey') const imageKeys = this.configService.getImageKeysForCurrentWxid()
const aesKeyRaw = this.configService.get('imageAesKey') || msg.aesKey const xorKeyRaw = imageKeys.xorKey
const aesKeyRaw = imageKeys.aesKey || msg.aesKey
if (!xorKeyRaw) return { success: false, error: '未配置图片 XOR 密钥,请在设置中自动获取' } if (!xorKeyRaw) return { success: false, error: '未配置图片 XOR 密钥,请在设置中自动获取' }

View File

@@ -637,6 +637,27 @@ export class ConfigService {
// === 工具方法 === // === 工具方法 ===
/**
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
*/
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
const wxid = this.get('myWxid')
if (wxid) {
const wxidConfigs = this.get('wxidConfigs')
const cfg = wxidConfigs?.[wxid]
if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) {
return {
xorKey: cfg.imageXorKey ?? this.get('imageXorKey'),
aesKey: cfg.imageAesKey ?? this.get('imageAesKey')
}
}
}
return {
xorKey: this.get('imageXorKey'),
aesKey: this.get('imageAesKey')
}
}
getCacheBasePath(): string { getCacheBasePath(): string {
return join(app.getPath('userData'), 'cache') return join(app.getPath('userData'), 'cache')
} }

View File

@@ -240,7 +240,9 @@ export class ImageDecryptService {
} }
} }
const xorKeyRaw = this.configService.get('imageXorKey') as unknown // 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
const imageKeys = this.configService.getImageKeysForCurrentWxid()
const xorKeyRaw = imageKeys.xorKey
// 支持十六进制格式(如 0x53和十进制格式 // 支持十六进制格式(如 0x53和十进制格式
let xorKey: number let xorKey: number
if (typeof xorKeyRaw === 'number') { if (typeof xorKeyRaw === 'number') {
@@ -257,7 +259,7 @@ export class ImageDecryptService {
return { success: false, error: '未配置图片解密密钥' } return { success: false, error: '未配置图片解密密钥' }
} }
const aesKeyRaw = this.configService.get('imageAesKey') const aesKeyRaw = imageKeys.aesKey
const aesKey = this.resolveAesKey(aesKeyRaw) const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })

View File

@@ -4,6 +4,7 @@ import { existsSync, copyFileSync, mkdirSync } from 'fs'
import { execFile, spawn } from 'child_process' import { execFile, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import os from 'os' import os from 'os'
import crypto from 'crypto'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -637,7 +638,16 @@ export class KeyService {
return { success: false, error: '获取密钥超时', logs } return { success: false, error: '获取密钥超时', logs }
} }
// --- Image Key (通过 DLL 从缓存目录直接获取) --- // --- Image Key (通过 DLL 从缓存目录获取 code用前端 wxid 计算密钥) ---
private cleanWxid(wxid: string): string {
// 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529
const first = wxid.indexOf('_')
if (first === -1) return wxid
const second = wxid.indexOf('_', first + 1)
if (second === -1) return wxid
return wxid.substring(0, second)
}
async autoGetImageKey( async autoGetImageKey(
manualDir?: string, manualDir?: string,
@@ -664,41 +674,51 @@ export class KeyService {
return { success: false, error: '解析密钥数据失败' } return { success: false, error: '解析密钥数据失败' }
} }
// 从 manualDir 中提取 wxid 用于精确匹配 // 从任意账号提取 code 列表code 来自 kvcomm与 wxid 无关,所有账号都一样)
// 前端传入的格式是 dbPath/wxid_xxx_1234取最后一段目录名再清理后缀 const accounts: any[] = parsed.accounts ?? []
let targetWxid: string | null = null if (!accounts.length || !accounts[0]?.keys?.length) {
return { success: false, error: '未找到有效的密钥码kvcomm 缓存为空)' }
}
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
// 从 manualDir 提取前端已配置好的正确 wxid
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
let targetWxid = ''
if (manualDir) { if (manualDir) {
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '' const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
// 与 DLL 的 CleanWxid 逻辑一致wxid_a_b_c → wxid_a if (dirName.startsWith('wxid_')) {
const parts = dirName.split('_')
if (parts.length >= 3 && parts[0] === 'wxid') {
targetWxid = `${parts[0]}_${parts[1]}`
} else if (dirName.startsWith('wxid_')) {
targetWxid = dirName targetWxid = dirName
} }
} }
const accounts: any[] = parsed.accounts ?? [] if (!targetWxid) {
if (!accounts.length) { // 无法从 manualDir 提取 wxid回退到 DLL 发现的第一个
return { success: false, error: '未找到有效的密钥组合' } targetWxid = accounts[0].wxid
console.log('[ImageKey] 无法从 manualDir 提取 wxid使用 DLL 发现的:', targetWxid)
} }
// 优先匹配 wxid找不到则回退到第一个 // CleanWxid: 截断到第二个下划线,与 xkey 算法一致
const matchedAccount = targetWxid const cleanedWxid = this.cleanWxid(targetWxid)
? (accounts.find((a: any) => a.wxid === targetWxid) ?? accounts[0]) console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid)
: accounts[0]
if (!matchedAccount?.keys?.length) { // 用 cleanedWxid + code 本地计算密钥
return { success: false, error: '未找到有效的密钥组合' } // xorKey = code & 0xFF
} // aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16)
const code = codes[0]
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
const firstKey = matchedAccount.keys[0] onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`)
onProgress?.(`密钥获取成功 (wxid: ${matchedAccount.wxid}, code: ${firstKey.code})`) console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey)
return { return {
success: true, success: true,
xorKey: firstKey.xorKey, xorKey,
aesKey: firstKey.aesKey aesKey
} }
} }
} }

View File

@@ -300,6 +300,7 @@ function ChatPage(_props: ChatPageProps) {
const [jumpStartTime, setJumpStartTime] = useState(0) const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0) const [jumpEndTime, setJumpEndTime] = useState(0)
const [showJumpDialog, setShowJumpDialog] = useState(false) const [showJumpDialog, setShowJumpDialog] = useState(false)
const isDateJumpRef = useRef(false)
const [messageDates, setMessageDates] = useState<Set<string>>(new Set()) const [messageDates, setMessageDates] = useState<Set<string>>(new Set())
const [loadingDates, setLoadingDates] = useState(false) const [loadingDates, setLoadingDates] = useState(false)
const messageDatesCache = useRef<Map<string, Set<string>>>(new Map()) const messageDatesCache = useRef<Map<string, Set<string>>>(new Map())
@@ -858,7 +859,7 @@ function ChatPage(_props: ChatPageProps) {
const currentBatchSizeRef = useRef(50) const currentBatchSizeRef = useRef(50)
// 加载消息 // 加载消息
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => { const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0, ascending = false) => {
const listEl = messageListRef.current const listEl = messageListRef.current
const session = sessionMapRef.current.get(sessionId) const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0 const unreadCount = session?.unreadCount ?? 0
@@ -892,7 +893,7 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try { try {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) as { const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime, ascending) as {
success: boolean; success: boolean;
messages?: Message[]; messages?: Message[];
hasMore?: boolean; hasMore?: boolean;
@@ -930,11 +931,16 @@ function ChatPage(_props: ChatPageProps) {
} }
} }
// 首次加载滚动到底部 // 日期跳转时滚动到顶部,否则滚动到底部
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (messageListRef.current) { if (messageListRef.current) {
if (isDateJumpRef.current) {
messageListRef.current.scrollTop = 0
isDateJumpRef.current = false
} else {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight messageListRef.current.scrollTop = messageListRef.current.scrollHeight
} }
}
}) })
} else { } else {
appendMessages(result.messages, true) appendMessages(result.messages, true)
@@ -966,8 +972,12 @@ function ChatPage(_props: ChatPageProps) {
}) })
} }
} }
// 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的
if (ascending) {
setHasMoreMessages(false)
setHasMoreLater(result.hasMore ?? false)
} else {
setHasMoreMessages(result.hasMore ?? false) setHasMoreMessages(result.hasMore ?? false)
// 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息
if (offset === 0) { if (offset === 0) {
if (endTime > 0) { if (endTime > 0) {
setHasMoreLater(true) setHasMoreLater(true)
@@ -975,6 +985,7 @@ function ChatPage(_props: ChatPageProps) {
setHasMoreLater(false) setHasMoreLater(false)
} }
} }
}
setCurrentOffset(offset + result.messages.length) setCurrentOffset(offset + result.messages.length)
} else if (!result.success) { } else if (!result.success) {
setNoMessageTable(true) setNoMessageTable(true)
@@ -2270,11 +2281,13 @@ function ChatPage(_props: ChatPageProps) {
onClose={() => setShowJumpDialog(false)} onClose={() => setShowJumpDialog(false)}
onSelect={(date) => { onSelect={(date) => {
if (!currentSessionId) return if (!currentSessionId) return
const start = Math.floor(date.setHours(0, 0, 0, 0) / 1000)
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000) const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
isDateJumpRef.current = true
setCurrentOffset(0) setCurrentOffset(0)
setJumpStartTime(0) setJumpStartTime(start)
setJumpEndTime(end) setJumpEndTime(end)
loadMessages(currentSessionId, 0, 0, end) loadMessages(currentSessionId, 0, start, end, true)
}} }}
messageDates={messageDates} messageDates={messageDates}
loadingDates={loadingDates} loadingDates={loadingDates}