diff --git a/README.md b/README.md index 2c4a2fd..a3ea76b 100644 --- a/README.md +++ b/README.md @@ -197,10 +197,18 @@ WechatExplorer 希望不仅仅是一个聊天记录查看工具,更希望成 ### 下载并安装 -从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载对应系统的安装包: +当前 WechatExplorer / 迹忆版本:`v2.1.6`。 -- Windows:下载 `-setup.exe` 并按安装向导完成安装。 -- macOS:下载 `.dmg`,将 WechatExplorer 拖入“应用程序”。首次打开若被系统拦截,请在“系统设置 → 隐私与安全性”中允许打开。 +应用安装包:[WechatExplorer GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。 + +| 系统 | 已测试的微信客户端 | +| ------- | ------------------------------------------------------------------------------------------------- | +| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | +| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | + +微信客户端来自上表对应的第三方版本存档,请自行核对来源与文件完整性。 + +正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录;但应用缓存和本地设置可能随版本升级变化。升级前仍建议使用微信官方迁移或备份功能备份重要记录,不要将唯一副本保存在单一设备。 ### 连接微信 diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index 9f6edca..968cf21 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -13,12 +13,14 @@ - [我遇到问题](#遇到问题) - [我想让 Agent 读取微信](#接入-api-reader-skill-或-agent) +> 正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录。应用缓存和本地设置可能随版本升级发生变化。系统故障、磁盘异常、误操作和微信自身迁移不受本应用控制,因此升级前仍建议使用微信官方迁移或备份功能备份重要聊天记录,不要将唯一副本保存在单一设备。 + ## 开始前确认 -| 系统 | 已测试的微信版本 | 需要注意 | -| --- | --- | --- | -| macOS | `4.1.8.100` | 自动获取数据库密钥前需要关闭 SIP 并完成授权 | -| Windows | `4.1.9.57` | 已完整支持;首次使用时请确认微信数据目录 | +| 系统 | 已测试的微信客户端 | 需要注意 | +| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前需要关闭 SIP 并完成授权 | +| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 已完整支持;首次使用时请确认微信数据目录 | - WechatExplorer 当前面向微信 4.0 数据结构。 - Windows 不需要关闭 SIP。 @@ -26,7 +28,7 @@ - WechatExplorer 必须取得当前微信账号对应的数据库密钥才能读取聊天记录。 - 请只处理你有权访问的微信数据。 -下载入口:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) +WechatExplorer / 迹忆应用安装包:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。微信客户端请使用上方“已测试的微信客户端”链接。 ## 第一次连接微信 @@ -140,15 +142,15 @@ AI 功能使用你配置的模型服务。相关聊天内容会按请求发送 先判断你遇到的现象,再按对应路径处理。 -| 现象 | 优先检查 | -| --- | --- | -| 软件打不开 | macOS 安全提示或应用损坏处理;Windows 重新运行安装包 | -| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 | -| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 | -| 数据库连接失败 | 当前账号是否匹配、微信版本是否兼容、数据库目录是否正确 | -| 已连接但图片不显示 | 配置图片 XOR Key 和 AES Key | -| AI 问问微信或日报不可用 | 在「设置 → AI 模型」配置并测试模型服务 | -| API、Reader Skill 或 Agent 不可用 | 先连接数据库,再确认 API 服务状态和对应配置 | +| 现象 | 优先检查 | +| --------------------------------- | -------------------------------------------------------- | +| 软件打不开 | macOS 安全提示或应用损坏处理;Windows 重新运行安装包 | +| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 | +| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 | +| 数据库连接失败 | 当前账号是否匹配、微信版本是否兼容、数据库目录是否正确 | +| 已连接但图片不显示 | 配置图片 XOR Key 和 AES Key | +| AI 问问微信或日报不可用 | 在「设置 → AI 模型」配置并测试模型服务 | +| API、Reader Skill 或 Agent 不可用 | 先连接数据库,再确认 API 服务状态和对应配置 | ### 软件打不开 diff --git a/src/main/database-key-store.ts b/src/main/database-key-store.ts index 4ed094a..9a05d7b 100644 --- a/src/main/database-key-store.ts +++ b/src/main/database-key-store.ts @@ -1,6 +1,7 @@ import { app, safeStorage } from 'electron' import fs from 'fs-extra' import path from 'path' +import crypto from 'crypto' import type { DatabaseKeyStorageResult } from '../shared/database-key' const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '') @@ -9,32 +10,42 @@ export const isValidDatabaseKey = (value: string): boolean => /^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value)) export class DatabaseKeyStore { - private get filePath(): string { + private get legacyFilePath(): string { return path.join(app.getPath('userData'), 'wechat-db-key.bin') } - async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> { + private get directoryPath(): string { + return path.join(app.getPath('userData'), 'database-keys') + } + + private filePath(accountRoot: string): string { + const normalized = path.resolve(accountRoot).toLowerCase() + const id = crypto.createHash('sha256').update(normalized).digest('hex') + return path.join(this.directoryPath, `${id}.bin`) + } + + async getStatus(accountRoot: string): Promise<{ saved: boolean; encryptionAvailable: boolean }> { return { - saved: await fs.pathExists(this.filePath), + saved: Boolean(accountRoot) && (await fs.pathExists(this.filePath(accountRoot))), encryptionAvailable: safeStorage.isEncryptionAvailable() } } - async load(): Promise { + async load(accountRoot: string): Promise { try { - const status = await this.getStatus() + const status = await this.getStatus(accountRoot) if (!status.saved) return { success: true, ...status } if (!status.encryptionAvailable) { return { success: false, error: '系统安全存储不可用', ...status } } - const encrypted = await fs.readFile(this.filePath) + const encrypted = await fs.readFile(this.filePath(accountRoot)) 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() + const status = await this.getStatus(accountRoot) return { success: false, error: error instanceof Error ? error.message : String(error), @@ -43,13 +54,49 @@ export class DatabaseKeyStore { } } - async save(rawKey: string): Promise { + async loadLegacy(): Promise { + const saved = await fs.pathExists(this.legacyFilePath) + const encryptionAvailable = safeStorage.isEncryptionAvailable() + if (!saved) return { success: true, saved, encryptionAvailable } + if (!encryptionAvailable) { + return { success: false, error: '系统安全存储不可用', saved, encryptionAvailable } + } + try { + const key = normalizeDatabaseKey( + safeStorage.decryptString(await fs.readFile(this.legacyFilePath)) + ) + return isValidDatabaseKey(key) + ? { success: true, key, saved, encryptionAvailable } + : { success: false, error: '旧版密钥格式无效', saved, encryptionAvailable } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + saved, + encryptionAvailable + } + } + } + + async clearLegacy(): Promise { + await fs.remove(this.legacyFilePath) + } + + async save(accountRoot: string, rawKey: string): Promise { const key = normalizeDatabaseKey(rawKey) + if (!accountRoot.trim()) { + return { + success: false, + error: '请先选择微信账号', + saved: false, + encryptionAvailable: safeStorage.isEncryptionAvailable() + } + } if (!isValidDatabaseKey(key)) { return { success: false, error: '密钥必须是 64 位十六进制字符', - saved: await fs.pathExists(this.filePath), + saved: await fs.pathExists(this.filePath(accountRoot)), encryptionAvailable: safeStorage.isEncryptionAvailable() } } @@ -57,29 +104,30 @@ export class DatabaseKeyStore { return { success: false, error: '系统安全存储不可用', - saved: await fs.pathExists(this.filePath), + saved: await fs.pathExists(this.filePath(accountRoot)), encryptionAvailable: false } } try { - await fs.ensureDir(path.dirname(this.filePath)) - await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 }) - await fs.chmod(this.filePath, 0o600) + const filePath = this.filePath(accountRoot) + await fs.ensureDir(this.directoryPath) + await fs.writeFile(filePath, safeStorage.encryptString(key), { mode: 0o600 }) + await fs.chmod(filePath, 0o600) return { success: true, key, saved: true, encryptionAvailable: true } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), - saved: await fs.pathExists(this.filePath), + saved: await fs.pathExists(this.filePath(accountRoot)), encryptionAvailable: true } } } - async clear(): Promise<{ success: boolean; error?: string }> { + async clear(accountRoot: string): Promise<{ success: boolean; error?: string }> { try { - await fs.remove(this.filePath) + if (accountRoot) await fs.remove(this.filePath(accountRoot)) return { success: true } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index ae2d5b5..bd7041d 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -1,6 +1,6 @@ import type { Message } from '../shared/types' -export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}` +export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}` const safe = (value: unknown): string => String(value ?? '').replace( /[&<>"']/g, @@ -14,7 +14,10 @@ export function renderExportPage(name: string, messages: Message[]): string { ? `` : safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1)) const audio = m.voiceDataUrl - ? `
` + ? `
` + : '' + const mediaStatus = m.exportMediaError + ? `
${safe(m.exportMediaError)}
` : '' const quote = m.contentData?.type === 'quote' @@ -34,8 +37,8 @@ export function renderExportPage(name: string, messages: Message[]): string { : `
${m.exportAvatarUrl ? `` : avatar}
` const isPat = m.contentData?.type === 'system' && m.contentData.pat const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '') - return `
${safe(m.datetime)}
${isPat ? '' : avatarMarkup}
${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}
${media}${audio}${quote}
${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}
` + return `
${safe(m.datetime)}
${isPat ? '' : avatarMarkup}
${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}
${media}${audio}${quote}
${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}
${mediaStatus}
` }) .join('') - return `${safe(name)} - 聊天记录
${safe(name)}${messages.length.toLocaleString()} 条消息
${body}
` + return `${safe(name)} - 聊天记录
${safe(name)}${messages.length.toLocaleString()} 条消息
${body}
` } diff --git a/src/main/export-service.ts b/src/main/export-service.ts index ad34e8c..73b0673 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -16,6 +16,7 @@ import { ImageDecryptService } from './image-decrypt-service' import { ImageKeyConfigService } from './services/image-key-config-service' import { VideoAssetService } from './video-asset-service' import { StickerService } from './sticker-service' +import { getImageExportAttempts } from '../shared/export-media' const jobs = new Set() const safeFilePart = (value: string): string => @@ -26,6 +27,10 @@ const exportStamp = (): string => { return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` } const imageKeys = new ImageKeyConfigService() + +const keepMediaError = (request: ExportRequest, message: Message, error: string): void => { + if (request.keepMissing !== false) message.exportMediaError = error +} function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null { const match = /^data:([^;]+);base64,(.+)$/s.exec(data) if (!match) return null @@ -105,11 +110,20 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri if (format === 'json') return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2) if (format === 'markdown') - return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}` + return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}` return [ - '时间,发送者,类型,内容', + '时间,发送者,类型,内容,媒体路径,媒体状态', ...messages.map((m) => - [m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',') + [ + m.datetime, + m.name || (m.isSender ? '我' : '联系人'), + m.type, + m.content, + m.exportMediaUrl || m.voiceDataUrl || '', + m.exportMediaError || '' + ] + .map(csv) + .join(',') ) ].join('\n') } @@ -126,9 +140,19 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro .listMessages(request.userMd5, request.startTime, request.endTime) .filter((m) => request.kinds.includes(kindOf(m))) for (const message of messages) { + message.exportMediaUrl = undefined + message.exportMediaType = undefined + message.exportMediaError = undefined + message.voiceDataUrl = undefined message.exportShowAvatar = request.includeAvatars !== false const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined if (mappedName) message.name = mappedName + if ( + request.format !== 'html' && + ['image', 'video', 'voice', 'sticker'].includes(kindOf(message)) + ) { + message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件' + } } send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 }) if (!jobs.has(request.jobId)) { @@ -181,25 +205,46 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro : null if (voiceService) { for (const [index, message] of messages.entries()) { - if ( - kindOf(message) !== 'voice' || - !message.sessionId || - !message.localId || - !message.createTime - ) + if (kindOf(message) !== 'voice') continue + if (!message.sessionId || message.localId == null || !message.createTime) { + keepMediaError(request, message, '语音标识不完整,无法定位本地语音') continue - const voice = await voiceService.resolveVoice( - message.sessionId, - message.localId, - message.createTime, - message.serverId - ) - if (!voice.success || !voice.data) continue - const voiceName = `voice_${index + 1}_${message.localId}.wav` - const audioBuffer = Buffer.from(voice.data, 'base64') - await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) - message.voiceDataUrl = `voices/${voiceName}` - message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) + } + try { + const voice = await voiceService.resolveVoice( + message.sessionId, + message.localId, + message.createTime, + message.serverId + ) + if (!voice.success || !voice.data) { + const detail = voice.error || '未知原因' + const reason = /未找到|不存在|获取语音数据失败/.test(detail) + ? `语音文件缺失:${detail}` + : /Silk|解码|数据为空/.test(detail) + ? `语音解析失败:${detail}` + : `语音格式不支持或读取失败:${detail}` + keepMediaError(request, message, reason) + continue + } + const voiceName = `voice_${index + 1}_${message.localId}.wav` + const audioBuffer = Buffer.from(voice.data, 'base64') + await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) + message.voiceDataUrl = `voices/${voiceName}` + message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) + } catch (error) { + keepMediaError( + request, + message, + `语音文件写入失败:${error instanceof Error ? error.message : String(error)}` + ) + } + } + } else if (request.includeMedia) { + for (const message of messages) { + if (kindOf(message) === 'voice') { + keepMediaError(request, message, '数据库未连接,无法读取本地语音') + } } } for (const [index, message] of messages.entries()) { @@ -228,34 +273,78 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro }) continue } - if (message.contentData.type === 'image' && imageService) { - const file = imageService.findImageFile( - message.contentData.md5, - message.contentData.datName, - { allowThumbnail: true } - ) - const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null - const decoded = decrypted ? decodeDataUrl(decrypted.data) : null - if (decoded) { - const name = `image_${index + 1}.${decoded.extension}` - await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) - message.exportMediaUrl = `media/${name}` - message.exportMediaType = 'image' + if (message.contentData.type === 'image') { + if (!imageService) { + keepMediaError(request, message, '未配置图片解密密钥,无法导出图片') + } else { + let fileFound = false + let decryptedImage: { data: string; filePath: string } | null = null + let usedFallback = false + for (const attempt of getImageExportAttempts(request)) { + const file = imageService.findImageFile( + message.contentData.md5, + message.contentData.datName, + { + allowThumbnail: attempt.allowThumbnail, + preferThumbnail: attempt.preferThumbnail, + sessionId: message.sessionId + } + ) + if (!file) continue + fileFound = true + const decrypted = imageService.decryptImageToBase64WithFallback( + file, + attempt.allowThumbnail + ) + if (!decrypted) continue + decryptedImage = decrypted + usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath) + break + } + const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null + if (decoded) { + const name = `image_${index + 1}.${decoded.extension}` + await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) + message.exportMediaUrl = `media/${name}` + message.exportMediaType = 'image' + if (usedFallback) { + keepMediaError(request, message, '原图不可用,已降级使用缩略图') + } + } else if (!fileFound) { + keepMediaError( + request, + message, + request.fallbackThumbnail === false + ? '原图文件缺失,未启用缩略图降级' + : '原图和缩略图文件均缺失' + ) + } else { + keepMediaError(request, message, '图片解析失败或当前格式不支持') + } } - } else if (message.contentData.type === 'video' && videoService) { + } else if (message.contentData.type === 'video') { const hashes = [ message.contentData.md5, message.contentData.newMd5, message.contentData.rawMd5 ].filter((value): value is string => Boolean(value)) - const resolved = videoService.resolve(hashes) - const token = resolved.url?.split('/').pop() - const source = token ? videoService.pathForToken(token) : undefined - if (source) { - const name = `video_${index + 1}.mp4` - await fs.copyFile(source, join(outputDir, 'media', name)) - message.exportMediaUrl = `media/${name}` - message.exportMediaType = 'video' + if (!videoService) { + keepMediaError(request, message, '数据库未连接,无法定位本地视频') + } else if (hashes.length === 0) { + keepMediaError(request, message, '视频标识不完整,无法定位本地视频') + } else { + const resolved = videoService.resolve(hashes) + const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined + if (!resolved.success || !source) { + keepMediaError(request, message, resolved.error || '视频文件缺失或已移动') + } else if (extname(source).toLowerCase() !== '.mp4') { + keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件') + } else { + const name = `video_${index + 1}.mp4` + await fs.copyFile(source, join(outputDir, 'media', name)) + message.exportMediaUrl = `media/${name}` + message.exportMediaType = 'video' + } } } else if (message.contentData.type === 'sticker' && stickerService) { const stickerSource = message.contentData.url || message.contentData.thumbUrl @@ -270,6 +359,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'sticker' + } else { + keepMediaError(request, message, result.error || '表情资源缺失或下载失败') } } send({ diff --git a/src/main/index.ts b/src/main/index.ts index fa904c6..c6fb1e1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -58,13 +58,25 @@ import * as chat from './services/chat-service' import { apiServer } from './http-server' import { skillResourceService } from './services/skill-resource-service' import { testLocalApiRequest } from './services/local-api-test-service' -import { isWindowsWechatRunning } from './services/wechat-process-status' +import { isWechatRunning } from './services/wechat-process-status' import { inspectImageDecryptionStatus, testImageDecryption } from './services/image-decryption-status-service' import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption' -import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store' +import { + loadSettings, + saveSettings, + getSettingsPath, + AppSettings, + validateDbRoot +} from './services/settings-store' +import { + detectDataStructureVersion, + detectWechatVersion, + getOsVersionLabel +} from './services/connection-diagnostics' +import { buildSafeDiagnosticSummary } from '../shared/connection-diagnostics' import { flushBootstrapCacheWritesSync, getBootstrapCache, @@ -88,6 +100,7 @@ import { configureRecallArchive, RecallArchiveMonitor } from './services/recall- import { VideoAssetService } from './video-asset-service' import { cancelExport, revealExport, runExport } from './export-service' import type { ExportRequest } from '../shared/export' +import { discoverAccounts } from './services/account-discovery' // electron-vite can close the child's stdout/stderr after spawning Electron. // Plain console.error then throws EPIPE on a closed pipe and crashes the IPC @@ -479,7 +492,7 @@ app.whenReady().then(async () => { return clearCache(scope) }) - ipcMain.handle('db:init', async (_, key: string) => { + ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => { if (dbInitInFlight) return dbInitInFlight dbInitInFlight = (async () => { @@ -489,17 +502,35 @@ app.whenReady().then(async () => { const trimmedKey = String(key || '').trim() console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`) const settings = loadSettings() + const selectedRoot = String(accountRoot || settings.dbRoot || '').trim() + const rootValidation = validateDbRoot(selectedRoot) + if (!rootValidation.valid) { + return { + success: false, + code: 'ROOT_UNAVAILABLE', + error: rootValidation.error, + monitoring: false + } + } + if (!existsSync(join(selectedRoot, 'db_storage'))) { + return { + success: false, + code: 'ACCOUNT_SELECTION_REQUIRED', + error: '请先明确选择一个微信账号', + monitoring: false + } + } if ( chat.isReady() && chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') && - (!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot) + chat.getCurrentAccountRoot() === selectedRoot ) { console.log('[WCDB4] db:init reuse current connection') return { success: true, monitoring: true } } - const nextWechatDb = await WechatDb.create(key, settings.dbRoot) + const nextWechatDb = await WechatDb.create(key, selectedRoot) const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot() - if (resolvedRoot && resolvedRoot !== settings.dbRoot) { + if (resolvedRoot) { // 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录 saveSettings({ ...settings, @@ -543,19 +574,43 @@ app.whenReady().then(async () => { return dbInitInFlight }) - ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load()) + ipcMain.handle('accounts:discover', (_, inputPath: string) => + discoverAccounts(inputPath, databaseKeyStore, chat.getCurrentAccountRoot()) + ) + + ipcMain.handle('key:getSavedDbKey', async (_, accountRoot: string) => { + const selectedRoot = String(accountRoot || '').trim() + const scoped = await databaseKeyStore.load(selectedRoot) + if (scoped.saved || !selectedRoot) return scoped + const legacy = await databaseKeyStore.loadLegacy() + if (!legacy.success || !legacy.key) return scoped + const validation = await chat.testConnection(legacy.key, selectedRoot) + if (!validation.success) return scoped + const migrated = await databaseKeyStore.save(selectedRoot, legacy.key) + if (migrated.success) await databaseKeyStore.clearLegacy() + return migrated + }) ipcMain.handle('key:getEnvironment', async () => { - const storage = await databaseKeyStore.getStatus() + const storage = await databaseKeyStore.getStatus( + chat.getCurrentAccountRoot() || loadSettings().dbRoot + ) const self = chat.getSelfAccountInfo() - return { + const settings = loadSettings() + const environment = { platform: process.platform, + osVersion: getOsVersionLabel(), + appVersion: `v${app.getVersion()}`, + wechatVersion: await detectWechatVersion(), + dataStructureVersion: detectDataStructureVersion(settings.dbRoot), + dataDirectoryDetected: validateDbRoot(settings.dbRoot).valid, autoDetectSupported: process.platform === 'win32', - wechatRunning: await isWindowsWechatRunning(), + wechatRunning: await isWechatRunning(), accountIdentified: Boolean(self?.wxid), dbConnected: chat.isReady(), encryptionAvailable: storage.encryptionAvailable } + return { ...environment, diagnosticSummary: buildSafeDiagnosticSummary(environment) } }) ipcMain.handle('key:readClipboardDbKey', () => { @@ -566,36 +621,47 @@ app.whenReady().then(async () => { } }) - ipcMain.handle('key:pasteAndSaveDbKey', async () => { + ipcMain.handle('key:pasteAndSaveDbKey', async (_, accountRoot: string) => { const clipboardKey = clipboard.readText().trim() - return databaseKeyStore.save(clipboardKey) + return databaseKeyStore.save(String(accountRoot || ''), clipboardKey) }) - ipcMain.handle('key:saveDbKey', async (_, key: string) => - databaseKeyStore.save(String(key || '')) + ipcMain.handle('key:saveDbKey', async (_, accountRoot: string, key: string) => + databaseKeyStore.save(String(accountRoot || ''), String(key || '')) ) - ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear()) + ipcMain.handle('key:clearSavedDbKey', async (_, accountRoot: string) => + databaseKeyStore.clear(String(accountRoot || '')) + ) - ipcMain.handle('key:autoGetDbKey', async (event, options?: { save?: boolean }) => { - const onStatus = (message: string): void => { - if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message }) + ipcMain.handle( + 'key:autoGetDbKey', + async (event, accountRoot: string, options?: { save?: boolean }) => { + const onStatus = (message: string): void => { + if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message }) + } + const result = + process.platform === 'win32' + ? await keyServiceWin.autoGetDbKey(60_000, onStatus) + : await keyServiceMac.autoGetDbKey(onStatus) + if (!result.success || !result.key) return result + + const selectedRoot = String(accountRoot || '').trim() + if (!selectedRoot) return { success: false, error: '请先选择微信账号' } + const validation = await chat.testConnection(result.key, selectedRoot) + if (!validation.success) { + return { ...result, success: false, key: undefined, error: '获取到的密钥不属于所选账号' } + } + if (options?.save === false) return result + + const saved = await databaseKeyStore.save(selectedRoot, result.key) + return { + ...result, + saved: saved.success, + warning: saved.success ? undefined : saved.error + } } - const result = - process.platform === 'win32' - ? await keyServiceWin.autoGetDbKey(60_000, onStatus) - : await keyServiceMac.autoGetDbKey(onStatus) - if (!result.success || !result.key) return result - - if (options?.save === false) return result - - const saved = await databaseKeyStore.save(result.key) - return { - ...result, - saved: saved.success, - warning: saved.success ? undefined : saved.error - } - }) + ) ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => { const settings = loadSettings() diff --git a/src/main/services/account-discovery.ts b/src/main/services/account-discovery.ts new file mode 100644 index 0000000..cf2e4b8 --- /dev/null +++ b/src/main/services/account-discovery.ts @@ -0,0 +1,58 @@ +import crypto from 'crypto' +import fs from 'fs-extra' +import path from 'path' +import type { AccountDiscoveryResult, WechatAccountCandidate } from '../../shared/database-key' +import { DatabaseKeyStore } from '../database-key-store' +import { getBootstrapCache } from './bootstrap-cache' +import { validateDbRoot } from './settings-store' + +function accountId(accountRoot: string): string { + return crypto.createHash('sha256').update(path.resolve(accountRoot).toLowerCase()).digest('hex') +} + +export async function discoverAccounts( + inputPath: string, + keyStore: DatabaseKeyStore, + currentAccountRoot?: string +): Promise { + const validation = validateDbRoot(inputPath) + if (!validation.valid) return { success: false, accounts: [], error: validation.error } + + const normalizedInput = path.resolve(inputPath) + const isAccount = await fs.pathExists(path.join(normalizedInput, 'db_storage')) + const roots = isAccount + ? [normalizedInput] + : (await fs.readdir(normalizedInput, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(normalizedInput, entry.name)) + .filter((candidate) => fs.existsSync(path.join(candidate, 'db_storage'))) + + const accounts: WechatAccountCandidate[] = await Promise.all( + roots.map(async (accountRoot) => { + const cached = getBootstrapCache(accountRoot)?.self + return { + id: accountId(accountRoot), + accountRoot, + directoryName: path.basename(accountRoot), + wxid: cached?.wxid, + nickname: cached?.nickname, + avatar: cached?.avatar, + hasSavedDbKey: (await keyStore.getStatus(accountRoot)).saved, + loginStatus: currentAccountRoot + ? path.resolve(currentAccountRoot).toLowerCase() === + path.resolve(accountRoot).toLowerCase() + ? 'current' + : 'other' + : 'unknown', + selectedByInput: isAccount + } + }) + ) + + return { + success: true, + inputKind: isAccount ? 'account' : 'root', + accounts, + preselectedAccountId: isAccount ? accounts[0]?.id : undefined + } +} diff --git a/src/main/services/connection-diagnostics.ts b/src/main/services/connection-diagnostics.ts new file mode 100644 index 0000000..9bd1a9a --- /dev/null +++ b/src/main/services/connection-diagnostics.ts @@ -0,0 +1,69 @@ +import { execFile } from 'child_process' +import fs from 'fs-extra' +import os from 'os' +import path from 'path' +import { promisify } from 'util' +import { isUsableDbRoot } from './settings-store' + +const execFileAsync = promisify(execFile) + +const platformLabel = (): string => { + if (process.platform === 'win32') return `Windows ${os.release()} (${process.arch})` + if (process.platform === 'darwin') return `macOS ${os.release()} (${process.arch})` + return `${process.platform} ${os.release()} (${process.arch})` +} + +async function detectWindowsWechatVersion(): Promise { + const script = [ + '$process = Get-Process Weixin,WeChat -ErrorAction SilentlyContinue | Where-Object Path | Select-Object -First 1', + '$candidate = if ($process) { $process.Path } else {', + " @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } | ForEach-Object { Join-Path $_ 'Tencent\\WeChat\\WeChat.exe' } | Where-Object { Test-Path $_ } | Select-Object -First 1", + '}', + 'if ($candidate) { (Get-Item -LiteralPath $candidate).VersionInfo.ProductVersion }' + ].join('; ') + try { + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { timeout: 3000, windowsHide: true } + ) + return stdout.trim() || '未检测到' + } catch { + return '未检测到' + } +} + +async function detectMacWechatVersion(): Promise { + const candidates = [ + '/Applications/WeChat.app/Contents/Info', + path.join(os.homedir(), 'Applications/WeChat.app/Contents/Info') + ] + for (const candidate of candidates) { + if (!fs.existsSync(`${candidate}.plist`)) continue + try { + const { stdout } = await execFileAsync( + '/usr/bin/defaults', + ['read', candidate, 'CFBundleShortVersionString'], + { timeout: 3000 } + ) + if (stdout.trim()) return stdout.trim() + } catch { + // Continue to the next known installation location. + } + } + return '未检测到' +} + +export async function detectWechatVersion(): Promise { + if (process.platform === 'win32') return detectWindowsWechatVersion() + if (process.platform === 'darwin') return detectMacWechatVersion() + return '未检测到' +} + +export function detectDataStructureVersion(dbRoot: string): string { + return isUsableDbRoot(dbRoot) ? '微信 4.x(WCDB)' : '未检测到' +} + +export function getOsVersionLabel(): string { + return platformLabel() +} diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 818f349..dbe4969 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -86,7 +86,7 @@ function unique(values: string[]): string[] { return Array.from(new Set(values)) } -function isUsableDbRoot(candidate?: string): boolean { +export function isUsableDbRoot(candidate?: string): boolean { if (!candidate || !fs.existsSync(candidate)) return false if (fs.existsSync(path.join(candidate, 'db_storage'))) return true try { @@ -98,6 +98,21 @@ function isUsableDbRoot(candidate?: string): boolean { } } +export function validateDbRoot(candidate?: string): { valid: boolean; error?: string } { + const root = String(candidate || '').trim() + if (!root) return { valid: false, error: '微信数据目录为空,请重新选择目录' } + if (!fs.existsSync(root)) { + return { valid: false, error: '微信数据目录不存在,请检查路径或重新选择目录' } + } + if (!isUsableDbRoot(root)) { + return { + valid: false, + error: '所选目录中未找到微信 4.x 数据库(db_storage),请选择 xwechat_files 或账号目录' + } + } + return { valid: true } +} + const defaultDbRoot = getDefaultDbRoot() const DEFAULT_SETTINGS: AppSettings = { diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index c8ce8a5..ea2288a 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -938,9 +938,27 @@ export class Wcdb4Client { ): Promise { const startedAt = Date.now() const maxRows = this.normalizeMessageLimit(options.limit) - const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows) + let cursorMessages: Wcdb4Message[] = [] + try { + cursorMessages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows) + } catch (error) { + console.warn(`[WCDB4] async cursor messages failed username=${username}:`, error) + } + + if (endTime && (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery)) { + throw new Error('当前数据服务无法检查历史消息分片,请更新应用或核对微信数据版本') + } + + // Older pages may live in message shards that the native cursor does not + // enumerate. A bounded query must inspect all matching stores so history + // cannot silently stop at a shard boundary. + let tableMessages: Wcdb4Message[] = [] + if (endTime || cursorMessages.length === 0) { + tableMessages = await this.getMessagesByTableScanAsync(username, startTime, endTime, maxRows) + } + const messages = this.mergeMessageRows(cursorMessages, tableMessages, maxRows) console.log( - `[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms` + `[WCDB4] getMessages async username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} cost=${Date.now() - startedAt}ms` ) return messages } @@ -998,6 +1016,71 @@ export class Wcdb4Client { return this.finalizeMessages(username, allRows, startTime, endTime, limit) } + private async getMessagesByTableScanAsync( + username: string, + startTime?: number, + endTime?: number, + limit?: number + ): Promise { + if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] + + let tables: Wcdb4MessageStore[] = [] + try { + const rows = await this.callJsonAsync[]>( + this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction, + username + ) + tables = (Array.isArray(rows) ? rows : []) + .map((row) => ({ + tableName: this.pickString(row, ['table_name', 'tableName', 'name']), + dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) + })) + .filter((row) => row.tableName && row.dbPath) + } catch (error) { + console.warn(`[WCDB4] async message table stats failed username=${username}:`, error) + throw new Error( + `无法读取历史消息分片信息:${error instanceof Error ? error.message : String(error)}` + ) + } + + const begin = this.normalizeTimestamp(startTime || 0) + const end = this.normalizeTimestamp(endTime || 0) + const where = [ + begin > 0 ? `"create_time" >= ${begin}` : '', + end > 0 ? `"create_time" <= ${end}` : '' + ].filter(Boolean) + const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : '' + const rowLimit = limit || 5000 + const order = limit ? 'DESC' : 'ASC' + + const allRows: Record[] = [] + let successfulTables = 0 + for (const table of tables) { + try { + const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}` + const rows = await this.callJsonAsync[]>( + this.wcdbExecQuery as unknown as KoffiAsyncFunction, + 'message', + table.dbPath, + sql + ) + successfulTables += 1 + if (Array.isArray(rows)) allRows.push(...rows) + } catch (error) { + console.warn( + `[WCDB4] async message table scan failed username=${username} db=${table.dbPath} table=${table.tableName}:`, + error + ) + } + } + + if (tables.length > 0 && successfulTables === 0) { + throw new Error('历史消息分片均读取失败,请检查数据目录或微信数据版本') + } + + return this.finalizeMessages(username, allRows, startTime, endTime, limit) + } + installRecallJournal(usernames: string[]): { installed: number; failed: number } { const stores = new Map() for (const username of this.uniq(usernames)) { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e8988c6..bcc3f49 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -11,7 +11,8 @@ import { import type { DatabaseKeyEnvironment, DatabaseKeyStorageResult, - DatabaseKeyValidationResult + DatabaseKeyValidationResult, + AccountDiscoveryResult } from '../shared/database-key' import type { ImageDecoderSelectionResult, @@ -115,8 +116,10 @@ declare global { getCacheSummary: () => Promise clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise initDb: ( - key: string + key: string, + accountRoot: string ) => Promise + discoverAccounts: (inputPath: string) => Promise getBootstrapCache: () => Promise<{ self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string } contacts: Contact[] @@ -239,14 +242,17 @@ declare global { ) => Promise deleteGeneratedReport: (reportId: string) => Promise revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> - getSavedDbKey: () => Promise + getSavedDbKey: (accountRoot: string) => Promise getDatabaseKeyEnvironment: () => Promise readDatabaseKeyClipboard: () => Promise<{ success: boolean value?: string error?: string }> - autoGetDbKey: (options?: { save?: boolean }) => Promise<{ + autoGetDbKey: ( + accountRoot: string, + options?: { save?: boolean } + ) => Promise<{ success: boolean key?: string error?: string @@ -290,9 +296,11 @@ declare global { request: TestImageDecryptionRequest ) => Promise clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }> - pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> - saveDbKey: (key: string) => Promise - clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> + pasteAndSaveDbKey: ( + accountRoot: string + ) => Promise<{ success: boolean; key?: string; error?: string }> + saveDbKey: (accountRoot: string, key: string) => Promise + clearSavedDbKey: (accountRoot: string) => Promise<{ success: boolean; error?: string }> onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 77c927d..322013f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,6 +21,7 @@ import type { AppUpdateState } from '../shared/app-update' import type { CacheSummary } from '../shared/cache' import type { ExportRequest, ExportJobProgress } from '../shared/export' import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption' +import type { AccountDiscoveryResult } from '../shared/database-key' // 渲染器的自定义 API const api = { @@ -40,7 +41,9 @@ const api = { getCacheSummary: (): Promise => ipcRenderer.invoke('cache:getSummary'), clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise => ipcRenderer.invoke('cache:clear', scope), - initDb: (key: string) => ipcRenderer.invoke('db:init', key), + initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot), + discoverAccounts: (inputPath: string): Promise => + ipcRenderer.invoke('accounts:discover', inputPath), getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'), getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'), getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter), @@ -98,10 +101,11 @@ const api = { deleteGeneratedReport: (reportId: string) => ipcRenderer.invoke('report:deleteGenerated', reportId), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), - getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'), + getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot), getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'), readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'), - autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options), + autoGetDbKey: (accountRoot: string, options?: { save?: boolean }) => + ipcRenderer.invoke('key:autoGetDbKey', accountRoot, options), autoGetImageKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetImageKey', options), getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'), @@ -115,9 +119,11 @@ const api = { saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request), testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request), clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'), - pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'), - saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key), - clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'), + pasteAndSaveDbKey: (accountRoot: string) => + ipcRenderer.invoke('key:pasteAndSaveDbKey', accountRoot), + saveDbKey: (accountRoot: string, key: string) => + ipcRenderer.invoke('key:saveDbKey', accountRoot, key), + clearSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:clearSavedDbKey', accountRoot), onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => { const listener = ( _event: Electron.IpcRendererEvent, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f2a35ae..df82b25 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -24,6 +24,7 @@ import { FirstUseWelcome } from './components/FirstUseWelcome' import { ExportWorkspace } from './components/export/ExportWorkspace' import { AISearchWorkspace } from './components/search/AISearchWorkspace' import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export' +import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key' import { getMessageIdentity, mergeMessagePages, @@ -32,6 +33,23 @@ import { const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MAX_WIDTH = 380 +const DATABASE_CONNECT_TIMEOUT_MS = 30_000 + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs) + promise.then( + (value) => { + window.clearTimeout(timer) + resolve(value) + }, + (error) => { + window.clearTimeout(timer) + reject(error) + } + ) + }) +} function getDevelopmentDatabaseKey(): string { if (!import.meta.env.DEV) return '' @@ -202,6 +220,7 @@ function App(): React.ReactElement { const [selectedContact, setSelectedContact] = useState(null) const [messages, setMessages] = useState([]) const [isMessagesLoading, setIsMessagesLoading] = useState(false) + const [messageHistoryStatus, setMessageHistoryStatus] = useState<'idle' | 'end' | 'error'>('idle') const [filteredContacts, setFilteredContacts] = useState([]) const [contentFilter, setContentFilter] = useState('') const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) @@ -209,10 +228,16 @@ function App(): React.ReactElement { const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal') const [showDbKey, setShowDbKey] = useState(false) const [dbRootInput, setDbRootInput] = useState('') + const [discoveredAccounts, setDiscoveredAccounts] = useState([]) + const [selectedAccountId, setSelectedAccountId] = useState('') + const selectedAccount = discoveredAccounts.find((account) => account.id === selectedAccountId) const [showMacKeyFaq, setShowMacKeyFaq] = useState(false) const [databaseConnectionMode, setDatabaseConnectionMode] = useState( getDevelopmentDatabaseKey() ? 'manual' : 'automatic' ) + const [connectionGuideStep, setConnectionGuideStep] = useState<1 | 2 | 3 | 4 | 5 | 6>(1) + const [databaseEnvironment, setDatabaseEnvironment] = useState() + const connectionOperationRef = React.useRef(0) const [activePage, setActivePage] = useState('archive') const [archiveJumpTime, setArchiveJumpTime] = useState(null) const [settingsCategory, setSettingsCategory] = useState('account-database') @@ -278,6 +303,34 @@ function App(): React.ReactElement { }) }) }, []) + + const refreshConnectionEnvironment = React.useCallback(async (): Promise => { + const root = dbRootInput.trim() + try { + if (root) { + const discovery = await window.api.discoverAccounts(root) + if (!discovery.success) throw new Error(discovery.error || '账号目录识别失败') + setDiscoveredAccounts(discovery.accounts) + setSelectedAccountId(discovery.preselectedAccountId || '') + } + const environment = await window.api.getDatabaseKeyEnvironment() + setDatabaseEnvironment(environment) + setDbKeyStatus('环境检查已更新') + setDbKeyStatusKind('normal') + } catch (error) { + setDbKeyStatus( + error instanceof Error ? `环境检查失败:${error.message}` : '环境检查失败,请重试' + ) + setDbKeyStatusKind('error') + } + }, [dbRootInput]) + + React.useEffect(() => { + void window.api + .getDatabaseKeyEnvironment() + .then(setDatabaseEnvironment) + .catch(() => undefined) + }, []) React.useEffect(() => { const loadAIConfig = async (): Promise => { try { @@ -533,13 +586,26 @@ function App(): React.ReactElement { if (active && settingsResult.settings.dbRoot) { setDbRootInput(settingsResult.settings.dbRoot) } + const discovery = settingsResult.settings.dbRoot + ? await window.api.discoverAccounts(settingsResult.settings.dbRoot) + : { success: false, accounts: [] } + if (active && discovery.success) { + setDiscoveredAccounts(discovery.accounts) + setSelectedAccountId(discovery.preselectedAccountId || '') + } + const startupAccountRoot = discovery.success + ? discovery.accounts.find((account) => account.id === discovery.preselectedAccountId) + ?.accountRoot + : undefined const autoLoginEnabled = settingsResult.settings.autoLogin // 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。 const envKey = getDevelopmentDatabaseKey() // 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。 let savedKey = '' if (!envKey) { - const result = await window.api.getSavedDbKey() + const result = startupAccountRoot + ? await window.api.getSavedDbKey(startupAccountRoot) + : { success: true, saved: false, encryptionAvailable: true } if (result.success && result.key) savedKey = result.key } const key = envKey || savedKey @@ -570,7 +636,11 @@ function App(): React.ReactElement { try { const startupCacheReady = await loadStartupCache() setIsDatabaseConnecting(true) - const initPromise = window.api.initDb(key) + if (!startupAccountRoot) { + setBootState('login') + return + } + const initPromise = window.api.initDb(key, startupAccountRoot) if (startupCacheReady) { setIsAuthenticated(true) setIsDatabaseConnected(false) @@ -655,13 +725,36 @@ function App(): React.ReactElement { void loadGeneratedReports() }, [isAuthenticated, loadGeneratedReports]) - const handleLogin = async (keyInput?: string): Promise => { + const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise => { const keyToUse = keyInput || dbKey - if (!keyToUse) return - setBootState('connecting') + let accountRoot = accountRootInput || selectedAccount?.accountRoot + if (!keyToUse || isDatabaseConnecting) return + if (!accountRoot) { + const discovery = await window.api.discoverAccounts(dbRootInput.trim()) + if (!discovery.success) { + setDbKeyStatus(discovery.error || '微信数据目录不可用') + setDbKeyStatusKind('error') + return + } + if (!discovery.preselectedAccountId) { + setDiscoveredAccounts(discovery.accounts) + setDbKeyStatus('请选择要连接的微信账号') + setDbKeyStatusKind('error') + return + } + const account = discovery.accounts.find( + (candidate) => candidate.id === discovery.preselectedAccountId + ) + if (!account) return + setDiscoveredAccounts(discovery.accounts) + setSelectedAccountId(account.id) + accountRoot = account.accountRoot + } + const operationId = ++connectionOperationRef.current + if (databaseConnectionMode === 'automatic') setConnectionGuideStep(6) setIsDatabaseConnecting(true) // 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot - const trimmedRoot = dbRootInput.trim() + const trimmedRoot = accountRoot if (trimmedRoot) { try { await window.api.setSettings({ dbRoot: trimmedRoot }) @@ -682,7 +775,12 @@ function App(): React.ReactElement { detail: '正在打开 WCDB 数据库', percent: 15 }) - const result = await window.api.initDb(keyToUse) + const result = await withTimeout( + window.api.initDb(keyToUse, trimmedRoot), + DATABASE_CONNECT_TIMEOUT_MS, + '数据库连接超时,请检查数据目录后重试' + ) + if (operationId !== connectionOperationRef.current) return const success = typeof result === 'boolean' ? result : result.success if (success) { setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) @@ -693,8 +791,9 @@ function App(): React.ReactElement { percent: 25 }) const hasBootstrap = await loadBootstrapCache() + if (operationId !== connectionOperationRef.current) return // 持久化手动输入的密钥,供下次启动继续使用 - void window.api.saveDbKey(keyToUse).catch(() => undefined) + void window.api.saveDbKey(trimmedRoot, keyToUse).catch(() => undefined) void window.api.getSettings().then((current) => { if (!current.settings.autoLoginPreferenceSet) { void window.api.setSettings({ autoLogin: true }) @@ -734,17 +833,23 @@ function App(): React.ReactElement { }, 500) } else { const error = typeof result === 'boolean' ? '' : result.error + setDbKeyStatus(error || '数据库连接失败,请检查密钥和数据目录后重试') + setDbKeyStatusKind('error') + if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5) setBootState('login') setStartupProgress(null) - alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`) } } catch (error) { console.error(error) + setDbKeyStatus( + error instanceof Error ? `数据库连接失败:${error.message}` : '数据库连接失败,请重试' + ) + setDbKeyStatusKind('error') + if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5) setBootState('login') setStartupProgress(null) - alert('Error connecting to database') } finally { - setIsDatabaseConnecting(false) + if (operationId === connectionOperationRef.current) setIsDatabaseConnecting(false) } } @@ -864,31 +969,42 @@ function App(): React.ReactElement { const handleAutoGetDbKey = async (): Promise => { if (isFetchingDbKey) return + const operationId = ++connectionOperationRef.current + setConnectionGuideStep(4) setIsFetchingDbKey(true) setDbKeyStatus('正在准备获取密钥...') setDbKeyStatusKind('normal') setShowMacKeyFaq(false) try { - const result = await window.api.autoGetDbKey() + if (!selectedAccount) throw new Error('请先选择微信账号') + const result = await window.api.autoGetDbKey(selectedAccount.accountRoot) + if (operationId !== connectionOperationRef.current) return if (!result.success || !result.key) { setShowMacKeyFaq(result.code === 'SCAN_FAILED') throw new Error(result.error || '获取密钥失败') } setDbKey(result.key) - setDatabaseConnectionMode('manual') + setConnectionGuideStep(5) setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取') setDbKeyStatusKind(result.saved ? 'success' : 'normal') } catch (error) { + if (operationId !== connectionOperationRef.current) return setDbKeyStatus(error instanceof Error ? error.message : String(error)) setDbKeyStatusKind('error') + setConnectionGuideStep(3) } finally { - setIsFetchingDbKey(false) + if (operationId === connectionOperationRef.current) setIsFetchingDbKey(false) } } const handlePasteAndSaveDbKey = async (): Promise => { setShowMacKeyFaq(false) - const result = await window.api.pasteAndSaveDbKey() + if (!selectedAccount) { + setDbKeyStatus('请先选择微信账号') + setDbKeyStatusKind('error') + return + } + const result = await window.api.pasteAndSaveDbKey(selectedAccount.accountRoot) if (result.success && result.key) { setDbKey(result.key) setDatabaseConnectionMode('manual') @@ -902,7 +1018,8 @@ function App(): React.ReactElement { const handleClearSavedDbKey = async (): Promise => { setShowMacKeyFaq(false) - const result = await window.api.clearSavedDbKey() + if (!selectedAccount) return + const result = await window.api.clearSavedDbKey(selectedAccount.accountRoot) if (!result.success) { setDbKeyStatus(result.error || '清除密钥失败') setDbKeyStatusKind('error') @@ -935,12 +1052,54 @@ function App(): React.ReactElement { setStartupProgress(null) } + const handleSwitchAccount = async (account: WechatAccountCandidate): Promise => { + connectionOperationRef.current += 1 + await window.api.disconnectDb({ closeNative: true }) + setIsAuthenticated(false) + setIsDatabaseConnected(false) + setSelectedContact(null) + setMessages([]) + setContacts([]) + setFilteredContacts([]) + setContentFilter('') + setSelfInfo(null) + setReportSourceContact(null) + setExportTasks([]) + messageHistoryRef.current = [] + messagesRef.current = [] + selectedContactMd5Ref.current = '' + currentGroupSnapshotRef.current = null + groupMemberMetaRef.current = {} + syntheticGroupMessagesRef.current = {} + setDiscoveredAccounts((current) => + current.some((item) => item.id === account.id) ? current : [account] + ) + setSelectedAccountId(account.id) + setDbRootInput(account.accountRoot) + await window.api.setSettings({ dbRoot: account.accountRoot, imageKeyRoot: account.accountRoot }) + const saved = await window.api.getSavedDbKey(account.accountRoot) + if (!saved.success || !saved.key) { + setDbKey('') + setDatabaseConnectionMode('automatic') + setBootState('login') + setConnectionGuideStep(3) + setDbKeyStatus('该账号尚无可用密钥,请为所选账号获取密钥') + setDbKeyStatusKind('normal') + return + } + setDbKey(saved.key) + setDatabaseConnectionMode('manual') + setBootState('login') + await handleLogin(saved.key, account.accountRoot) + } + const handleSelectContact = async (contact: Contact, forceLive = false): Promise => { setArchiveJumpTime(null) setSelectedContact(contact) selectedContactMd5Ref.current = contact.md5 currentGroupSnapshotRef.current = null setIsMessagesLoading(true) + setMessageHistoryStatus('idle') const cachedPage = await window.api.getCachedMessagePage(contact.md5) const cachedMsgs = cachedPage.messages if (selectedContactMd5Ref.current !== contact.md5) return @@ -1052,7 +1211,7 @@ function App(): React.ReactElement { const handleLoadOlderMessages = async (): Promise => { const contact = selectedContact - if (!contact || messagesRef.current.length === 0) return + if (!contact || messagesRef.current.length === 0 || messageHistoryStatus === 'end') return if (messagePrefetchRef.current) await messagePrefetchRef.current if (selectedContactMd5Ref.current !== contact.md5) return const currentMessages = messagesRef.current @@ -1089,6 +1248,7 @@ function App(): React.ReactElement { limit: MESSAGE_PAGE_SIZE }) if (selectedContactMd5Ref.current !== contact.md5) return + setMessageHistoryStatus(olderMessages.length === 0 ? 'end' : 'idle') messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages) setMessages((current) => applyGroupMemberMeta( @@ -1098,6 +1258,7 @@ function App(): React.ReactElement { ) } catch (error) { console.warn('[Messages] older page load failed:', error) + if (selectedContactMd5Ref.current === contact.md5) setMessageHistoryStatus('error') } finally { if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false) } @@ -1434,6 +1595,7 @@ function App(): React.ReactElement { contact={selectedContact} messages={messages} isLoadingMessages={isMessagesLoading} + messageHistoryStatus={messageHistoryStatus} contentFilter={contentFilter} onContentFilterChange={setContentFilter} onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)} @@ -1561,6 +1723,7 @@ function App(): React.ReactElement { onNotice={setReportNotice} onOpenSettings={openSettings} onAppearanceChange={handleAppearanceChange} + onSwitchAccount={handleSwitchAccount} /> ) case 'search': @@ -1675,15 +1838,80 @@ function App(): React.ReactElement { dbRoot={dbRootInput} showDbKey={showDbKey} isFetching={isFetchingDbKey} + isConnecting={isDatabaseConnecting} + guideStep={connectionGuideStep} + environment={databaseEnvironment} + accounts={discoveredAccounts} + selectedAccountId={selectedAccountId} status={dbKeyStatus} statusKind={dbKeyStatusKind} showMacKeyFaq={showMacKeyFaq} macKeyFaqUrl={MAC_KEY_FAQ_URL} onModeChange={setDatabaseConnectionMode} onDbKeyChange={setDbKey} - onDbRootChange={setDbRootInput} + onDbRootChange={(value) => { + setDbRootInput(value) + setDiscoveredAccounts([]) + setSelectedAccountId('') + }} + onSelectAccount={(account) => { + setSelectedAccountId(account.id) + setDbKey('') + void window.api.getSavedDbKey(account.accountRoot).then((result) => { + if (result.success && result.key) setDbKey(result.key) + }) + }} + onSelectDbRoot={() => { + void window.api.selectDbRoot().then((result) => { + if (!result.canceled && result.path) { + setDbRootInput(result.path) + void window.api.discoverAccounts(result.path).then((discovery) => { + if (!discovery.success) { + setDiscoveredAccounts([]) + setSelectedAccountId('') + setDbKeyStatus(discovery.error || '账号目录识别失败') + setDbKeyStatusKind('error') + return + } + setDiscoveredAccounts(discovery.accounts) + setSelectedAccountId(discovery.preselectedAccountId || '') + }) + } + }) + }} onToggleDbKey={() => setShowDbKey((visible) => !visible)} onAutoGetKey={handleAutoGetDbKey} + onRefreshEnvironment={() => void refreshConnectionEnvironment()} + onGuideNext={() => + setConnectionGuideStep((current) => (current === 1 ? 2 : current === 2 ? 3 : current)) + } + onGuideBack={() => + setConnectionGuideStep((current) => + current === 5 ? 3 : current > 1 ? ((current - 1) as 1 | 2 | 3 | 4 | 5 | 6) : 1 + ) + } + onGuideCancel={() => { + connectionOperationRef.current += 1 + setIsFetchingDbKey(false) + setIsDatabaseConnecting(false) + setBootState('login') + setStartupProgress(null) + setConnectionGuideStep(1) + setDbKeyStatus('已取消,可以重新检查环境') + setDbKeyStatusKind('normal') + }} + onValidateConnection={() => void handleLogin()} + onCopyDiagnostics={() => { + if (!databaseEnvironment?.diagnosticSummary) { + setDbKeyStatus('诊断信息尚未准备好,请先重新检查环境') + setDbKeyStatusKind('error') + return + } + void window.api.copyText(databaseEnvironment.diagnosticSummary).then(() => { + setDbKeyStatus('脱敏诊断摘要已复制') + setDbKeyStatusKind('success') + }) + }} onManualConnect={() => handleLogin()} onPasteKey={handlePasteAndSaveDbKey} onClearKey={handleClearSavedDbKey} diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index c4de2ec..3a86317 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -10,6 +10,7 @@ interface ChatWindowProps { contact: Contact | null messages: Message[] isLoadingMessages?: boolean + messageHistoryStatus?: 'idle' | 'end' | 'error' contentFilter?: string onContentFilterChange?: (keyword: string) => void onRefresh?: () => void @@ -25,6 +26,7 @@ const ChatWindow: React.FC = ({ contact, messages, isLoadingMessages, + messageHistoryStatus, contentFilter, onContentFilterChange, onRefresh, @@ -205,6 +207,7 @@ const ChatWindow: React.FC = ({ messages={filteredMessages} hiddenMessageCount={0} isLoadingMessages={isLoadingMessages} + messageHistoryStatus={messageHistoryStatus} isGroupChat={isGroupChat} showAvatar={showAvatar} listRef={messageListRef} diff --git a/src/renderer/src/components/DatabaseConnectionPage.tsx b/src/renderer/src/components/DatabaseConnectionPage.tsx index f94699c..6dc667c 100644 --- a/src/renderer/src/components/DatabaseConnectionPage.tsx +++ b/src/renderer/src/components/DatabaseConnectionPage.tsx @@ -1,4 +1,5 @@ import React from 'react' +import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key' const GUIDE_URL = 'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md' @@ -13,6 +14,11 @@ interface DatabaseConnectionPageProps { dbRoot: string showDbKey: boolean isFetching: boolean + isConnecting: boolean + guideStep: 1 | 2 | 3 | 4 | 5 | 6 + environment?: DatabaseKeyEnvironment + accounts: WechatAccountCandidate[] + selectedAccountId: string status: string statusKind: DatabaseConnectionStatusKind showMacKeyFaq: boolean @@ -20,8 +26,16 @@ interface DatabaseConnectionPageProps { onModeChange: (mode: DatabaseConnectionMode) => void onDbKeyChange: (value: string) => void onDbRootChange: (value: string) => void + onSelectAccount: (account: WechatAccountCandidate) => void + onSelectDbRoot: () => void onToggleDbKey: () => void onAutoGetKey: () => void + onRefreshEnvironment: () => void + onGuideNext: () => void + onGuideBack: () => void + onGuideCancel: () => void + onValidateConnection: () => void + onCopyDiagnostics: () => void onManualConnect: () => void onPasteKey: () => void onClearKey: () => void @@ -92,6 +106,11 @@ export function DatabaseConnectionPage({ dbRoot, showDbKey, isFetching, + isConnecting, + guideStep, + environment, + accounts = [], + selectedAccountId = '', status, statusKind, showMacKeyFaq, @@ -99,8 +118,16 @@ export function DatabaseConnectionPage({ onModeChange, onDbKeyChange, onDbRootChange, + onSelectAccount, + onSelectDbRoot, onToggleDbKey, onAutoGetKey, + onRefreshEnvironment, + onGuideNext, + onGuideBack, + onGuideCancel, + onValidateConnection, + onCopyDiagnostics, onManualConnect, onPasteKey, onClearKey @@ -146,27 +173,27 @@ export function DatabaseConnectionPage({

第一次使用

开始连接微信

-

跟着下面 3 步操作,通常几分钟即可完成连接。

+

按顺序检查环境、准备连接组件并验证数据库,通常几分钟即可完成。

  1. 1
    - 确认微信数据目录 - 没有自动找到时,可在设置中手动选择 + 检查本机环境 + 确认微信版本、数据目录和运行状态
  2. 2
    - 让微信停在登录页面 - 不要在获取密钥前完成登录 + 准备连接组件 + 页面会按当前系统给出对应步骤
  3. 3
    - 点击自动获取密钥 - 提示可以登录后,再回到微信完成登录 + 登录并验证连接 + 验证通过后进入主界面
@@ -202,6 +229,11 @@ export function DatabaseConnectionPage({ {mode === 'automatic' ? (
+
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
@@ -209,57 +241,206 @@ export function DatabaseConnectionPage({
- {statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'} + {statusKind === 'error' + ? '当前步骤未完成' + : [ + '检查本机环境', + '让微信停在登录页面', + '确认开始准备', + `正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`, + '现在可以登录微信', + '验证数据库连接' + ][guideStep - 1]}

{statusKind === 'error' ? status - : status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'} + : status || + [ + '确认下方检测结果;没有找到目录时可以手动选择。', + '请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。', + '开始后请按页面提示完成系统授权。', + '正在准备连接组件,请不要关闭微信或 WechatExplorer。', + '请回到微信完成登录,登录成功后再回来验证。', + '正在验证密钥和本地数据库,请稍候。' + ][guideStep - 1]}

-
-
-
微信客户端
-
{isFetching ? '正在检测' : '等待检测'}
-
-
-
- 存储路径 - -
-
- - onDbRootChange(event.target.value)} - placeholder={defaultPath} - title={dbRoot || defaultPath} - aria-label="微信数据存储路径" - spellCheck={false} - onFocus={(event) => event.currentTarget.select()} - /> - - {dbRoot || defaultPath} - - -
-
-
-
数据库状态
-
{statusKind === 'error' ? '无法连接' : '准备连接'}
-
-
+ {guideStep === 1 && ( + <> +
+
+
操作系统
+
{environment?.osVersion || (isMac ? 'macOS' : 'Windows')}
+
+
+
微信版本
+
{environment?.wechatVersion || '未检测到'}
+
+
+
数据结构
+
{environment?.dataStructureVersion || '未检测到'}
+
+
+
+ 存储路径 + +
+
+ + onDbRootChange(event.target.value)} + placeholder={defaultPath} + title={dbRoot || defaultPath} + aria-label="微信数据存储路径" + spellCheck={false} + onFocus={(event) => event.currentTarget.select()} + /> + + {dbRoot || defaultPath} + + + +
+
+
+
微信状态
+
{environment?.wechatRunning ? '运行中' : '未检测到'}
+
+
+ {accounts.length > 0 && ( +
+

选择微信账号

+ {accounts.map((account) => ( + + ))} + +
+ )} + + )}
- + {guideStep === 1 && ( + <> + + + + + )} + {guideStep === 2 && ( + + )} + {guideStep === 3 && ( + + )} + {guideStep === 4 && ( + + )} + {guideStep === 5 && ( + + )} + {guideStep === 6 && ( + + )} + {guideStep > 1 && !isFetching && !isConnecting && ( +
+ + +
+ )} + {(isFetching || isConnecting) && ( + + )}

{isMac ? ( <> @@ -311,15 +492,21 @@ export function DatabaseConnectionPage({ 微信数据目录 - onDbRootChange(event.target.value)} - placeholder={defaultPath} - title={dbRoot || defaultPath} - spellCheck={false} - onFocus={(event) => event.currentTarget.select()} - /> +

+ onDbRootChange(event.target.value)} + placeholder={defaultPath} + title={dbRoot || defaultPath} + spellCheck={false} + onFocus={(event) => event.currentTarget.select()} + /> + +
)} {status &&
{status}
} @@ -327,11 +514,25 @@ export function DatabaseConnectionPage({ type="button" className="database-login-primary" onClick={onManualConnect} - disabled={!keyIsValid} + disabled={!keyIsValid || isConnecting} > - 连接数据库 + {isConnecting ? '正在连接…' : '连接数据库'} - + )} +
diff --git a/src/renderer/src/components/chat/MessageList.tsx b/src/renderer/src/components/chat/MessageList.tsx index d2fe589..6339135 100644 --- a/src/renderer/src/components/chat/MessageList.tsx +++ b/src/renderer/src/components/chat/MessageList.tsx @@ -9,6 +9,7 @@ interface MessageListProps { messages: Message[] hiddenMessageCount: number isLoadingMessages?: boolean + messageHistoryStatus?: 'idle' | 'end' | 'error' isGroupChat: boolean showAvatar: boolean listRef: React.RefObject @@ -24,6 +25,7 @@ export function MessageList({ messages, hiddenMessageCount, isLoadingMessages, + messageHistoryStatus, isGroupChat, showAvatar, listRef, @@ -119,6 +121,18 @@ export function MessageList({ return (
{isLoadingMessages &&
正在加载聊天记录...
} + {messageHistoryStatus === 'end' && ( +
+
已显示本地数据库中的最早记录
+
+ )} + {messageHistoryStatus === 'error' && ( +
+
+ 无法读取更早记录,请检查数据目录或当前微信数据版本 +
+
+ )} {hiddenMessageCount > 0 && (
diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx index 4c0bdc5..f79238e 100644 --- a/src/renderer/src/components/export/ExportWorkspace.tsx +++ b/src/renderer/src/components/export/ExportWorkspace.tsx @@ -41,7 +41,7 @@ export function ExportWorkspace({ const [includeAvatars, setIncludeAvatars] = useState(true) const [preferOriginal, setPreferOriginal] = useState(true) const [fallbackThumbnail, setFallbackThumbnail] = useState(true) - const [keepMissing, setKeepMissing] = useState(false) + const [keepMissing, setKeepMissing] = useState(true) const [format, setFormat] = useState('csv') const [zip, setZip] = useState(false) const [fileName, setFileName] = useState('') @@ -158,6 +158,8 @@ export function ExportWorkspace({ const handleStart = async (): Promise => { if (!activeContact || status === 'running') return + // Runs only from the export button event; a fresh id is required for each job. + // eslint-disable-next-line react-hooks/purity const nextJobId = `export-${Date.now()}` setJobId(nextJobId) setProgress(null) @@ -204,6 +206,9 @@ export function ExportWorkspace({ : undefined, kinds: Array.from(selectedKinds) as ExportMessageKind[], includeMedia, + preferOriginal, + fallbackThumbnail, + keepMissing, includeAvatars, avatarUrls: exportAvatarUrls, nameMode, @@ -303,20 +308,39 @@ export function ExportWorkspace({

导出格式

{formatOrder.map((value) => ( - ))}
-

CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。

+

+ CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。 +

{format === 'html' && (
)} @@ -383,19 +407,13 @@ export function ExportWorkspace({

消息内容

{messageKinds.map(([value, label]) => ( -
@@ -424,12 +442,14 @@ export function ExportWorkspace({ 包含图片、视频、语音及动态表情 setIncludeMedia(event.target.checked)} /> -
+
-

资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。

+

+ 资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。 +

图片解密:已就绪 视频资源:可用 diff --git a/src/renderer/src/features/settings/SettingsWorkspace.tsx b/src/renderer/src/features/settings/SettingsWorkspace.tsx index ebdd020..78cdc1b 100644 --- a/src/renderer/src/features/settings/SettingsWorkspace.tsx +++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx @@ -30,7 +30,8 @@ export function SettingsWorkspace({ onAIRuntimeChange, onNotice, onOpenSettings, - onAppearanceChange + onAppearanceChange, + onSwitchAccount }: { selectedCategory: SettingsCategoryId onCategoryChange: (id: SettingsCategoryId) => void @@ -47,7 +48,13 @@ export function SettingsWorkspace({ onAIRuntimeChange: (config: AIRuntimeModelConfig) => void onNotice: (message: string) => void onOpenSettings: () => void - onAppearanceChange: (settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => void + onAppearanceChange: (settings: { + theme: 'system' | 'light' | 'dark' + compactMode: boolean + }) => void + onSwitchAccount: ( + account: import('../../../../shared/database-key').WechatAccountCandidate + ) => Promise }): React.ReactElement { const renderSelectedPage = (): React.ReactElement => { switch (selectedCategory) { @@ -59,6 +66,7 @@ export function SettingsWorkspace({ dbConnecting={dbConnecting} selfInfo={selfInfo} onNotice={onNotice} + onSwitchAccount={onSwitchAccount} /> ) case 'database-key': @@ -87,9 +95,7 @@ export function SettingsWorkspace({ case 'cache-cleanup': return case 'appearance': - return ( - - ) + return case 'about': return default: diff --git a/src/renderer/src/features/settings/account-database/AccountOverview.tsx b/src/renderer/src/features/settings/account-database/AccountOverview.tsx index 23c5a56..9bca65c 100644 --- a/src/renderer/src/features/settings/account-database/AccountOverview.tsx +++ b/src/renderer/src/features/settings/account-database/AccountOverview.tsx @@ -25,7 +25,8 @@ export function AccountOverview({ isChecking, onCheck, onOpenDirectory, - onCopyDirectory + onCopyDirectory, + onSwitchAccount }: { selfInfo: SettingsSelfInfo | null connectionStatus: ConnectionOverviewStatus @@ -34,6 +35,7 @@ export function AccountOverview({ onCheck: () => void onOpenDirectory: () => void onCopyDirectory: () => void + onSwitchAccount: () => void }): React.ReactElement { const accountRoot = selfInfo?.accountRoot || '' return ( @@ -83,6 +85,9 @@ export function AccountOverview({ > 打开账号目录 +
diff --git a/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts b/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts index a58d358..0da86f7 100644 --- a/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts +++ b/src/renderer/src/features/settings/database-key/useDatabaseKeyController.ts @@ -36,14 +36,14 @@ export function useDatabaseKeyController({ }, []) const refreshStorage = useCallback(async (): Promise => { - const result = await window.api.getSavedDbKey() + const result = await window.api.getSavedDbKey(selfInfo?.accountRoot || '') dispatch({ type: 'STORAGE_LOADED', saved: result.saved, encryptionAvailable: result.encryptionAvailable, error: result.success ? undefined : result.error }) - }, []) + }, [selfInfo?.accountRoot]) useEffect(() => { void Promise.all([refreshStorage(), refreshEnvironment()]) @@ -109,7 +109,8 @@ export function useDatabaseKeyController({ const saveKey = useCallback(async (): Promise => { if (state.status !== 'valid') return dispatch({ type: 'SAVE_START' }) - const saved = await window.api.saveDbKey(dbKey) + const accountRoot = selfInfo?.accountRoot || '' + const saved = await window.api.saveDbKey(accountRoot, dbKey) if (!saved.success || !saved.key) { dispatch({ type: 'SAVE_ERROR', @@ -117,12 +118,12 @@ export function useDatabaseKeyController({ }) return } - const stored = await window.api.getSavedDbKey() + const stored = await window.api.getSavedDbKey(accountRoot) if (!stored.success || !stored.saved || !stored.key) { dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' }) return } - const initialized = await window.api.initDb(stored.key) + const initialized = await window.api.initDb(stored.key, accountRoot) const connected = typeof initialized === 'boolean' ? initialized : initialized.success onDbKeyChange(stored.key) onDatabaseConnectionChange(connected) @@ -148,13 +149,14 @@ export function useDatabaseKeyController({ onNotice, onSelfInfoChange, refreshEnvironment, + selfInfo?.accountRoot, state.status ]) const autoDetectKey = useCallback(async (): Promise => { dispatch({ type: 'AUTO_START' }) await refreshEnvironment() - const result = await window.api.autoGetDbKey({ save: false }) + const result = await window.api.autoGetDbKey(selfInfo?.accountRoot || '', { save: false }) if (!result.success || !result.key) { dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' }) return @@ -162,11 +164,11 @@ export function useDatabaseKeyController({ onDbKeyChange(result.key) dispatch({ type: 'AUTO_SUCCESS' }) await runValidation(result.key) - }, [onDbKeyChange, refreshEnvironment, runValidation]) + }, [onDbKeyChange, refreshEnvironment, runValidation, selfInfo?.accountRoot]) const clearSavedKey = useCallback(async (): Promise => { dispatch({ type: 'CLEAR_START' }) - const result = await window.api.clearSavedDbKey() + const result = await window.api.clearSavedDbKey(selfInfo?.accountRoot || '') if (!result.success) { dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' }) return @@ -187,7 +189,8 @@ export function useDatabaseKeyController({ onFilteredContactsChange, onNotice, onSelfInfoChange, - refreshEnvironment + refreshEnvironment, + selfInfo?.accountRoot ]) const returnToLogin = useCallback(async (): Promise => { diff --git a/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx b/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx index a974662..d2e8d3a 100644 --- a/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx +++ b/src/renderer/src/features/settings/pages/AccountDatabasePage.tsx @@ -5,6 +5,7 @@ import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice' import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController' import type { ConnectionOverviewStatus } from '../account-database/types' import type { SettingsSelfInfo } from '../model/types' +import type { WechatAccountCandidate } from '../../../../../shared/database-key' const STATUS_LABELS: Record = { checking: '正在检测', @@ -19,13 +20,15 @@ export function AccountDatabasePage({ dbReady, dbConnecting = false, selfInfo, - onNotice + onNotice, + onSwitchAccount }: { dbKey: string dbReady: boolean dbConnecting?: boolean selfInfo: SettingsSelfInfo | null onNotice: (message: string) => void + onSwitchAccount: (account: WechatAccountCandidate) => Promise }): React.ReactElement { const controller = useAccountDatabaseController({ dbKey, @@ -35,6 +38,8 @@ export function AccountDatabasePage({ onNotice }) const [autoLogin, setAutoLogin] = useState(false) + const [switching, setSwitching] = useState(false) + const [accounts, setAccounts] = useState([]) useEffect(() => { let active = true @@ -56,6 +61,18 @@ export function AccountDatabasePage({ onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接') } + const openAccountSwitcher = async (): Promise => { + if (!selfInfo?.accountRoot) return + const parentRoot = selfInfo.accountRoot.replace(/[\\/][^\\/]+[\\/]?$/, '') + const result = await window.api.discoverAccounts(parentRoot) + if (!result.success) { + onNotice(result.error || '无法读取账号列表') + return + } + setAccounts(result.accounts) + setSwitching(true) + } + return (
@@ -79,7 +96,45 @@ export function AccountDatabasePage({ onCheck={() => void controller.testConnection()} onOpenDirectory={() => void controller.openAccountDirectory()} onCopyDirectory={() => void controller.copyAccountDirectory()} + onSwitchAccount={() => void openAccountSwitcher()} /> + {switching && ( +
+

选择要切换的账号

+ {accounts.map((account) => ( + + ))} + +
+ )}

连接健康检查

input, +.database-login-root-control, .database-login-key-input { width: 100%; min-height: 42px; @@ -1604,6 +1657,37 @@ background: #fff; } +.database-login-root-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + overflow: hidden; +} + +.database-login-root-control input { + min-width: 0; + padding: 0 12px; + border: 0; + outline: 0; + color: var(--login-text); + background: transparent; + font-size: 13px; +} + +.database-login-root-control button { + padding: 0 12px; + border: 0; + border-left: 1px solid var(--login-border); + color: var(--login-primary-dark); + background: var(--login-surface-low); + cursor: pointer; +} + +.database-login-root-control button:disabled, +.database-login-path-select:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .database-login-field > input { padding: 0 12px; color: var(--login-text); @@ -1617,6 +1701,7 @@ } .database-login-key-input:focus-within, +.database-login-root-control:focus-within, .database-login-field > input:focus { border-color: var(--login-primary); box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1); @@ -1969,3 +2054,70 @@ pointer-events: none; } } +.database-account-list { + display: grid; + gap: 10px; + margin: 14px 0; + + h3 { + margin: 0; + font-size: 14px; + } +} + +.database-account-card { + width: 100%; + display: grid; + grid-template-columns: 44px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px; + border: 1px solid var(--border-color, #d8e2dc); + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + + &.selected { + border-color: #176b57; + box-shadow: 0 0 0 2px #176b5720; + } +} + +.database-account-avatar { + width: 44px; + height: 44px; + display: grid; + place-items: center; + overflow: hidden; + border-radius: 6px; + background: #dcebe4; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.database-account-identity, +.database-account-status { + display: grid; + gap: 3px; + min-width: 0; + + small, + code { + color: var(--text-secondary, #68766f); + } + code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.database-account-status { + text-align: right; +} diff --git a/src/shared/connection-diagnostics.ts b/src/shared/connection-diagnostics.ts new file mode 100644 index 0000000..3865f93 --- /dev/null +++ b/src/shared/connection-diagnostics.ts @@ -0,0 +1,16 @@ +import type { DatabaseKeyEnvironment } from './database-key' + +export function buildSafeDiagnosticSummary( + environment: Omit +): string { + return [ + `WechatExplorer: ${environment.appVersion}`, + `操作系统: ${environment.osVersion}`, + `微信客户端: ${environment.wechatVersion}`, + `数据结构: ${environment.dataStructureVersion}`, + `数据目录: ${environment.dataDirectoryDetected ? '已检测到' : '未检测到'}`, + `微信进程: ${environment.wechatRunning ? '运行中' : '未运行'}`, + `数据库连接: ${environment.dbConnected ? '已连接' : '未连接'}`, + `安全存储: ${environment.encryptionAvailable ? '可用' : '不可用'}` + ].join('\n') +} diff --git a/src/shared/database-key.ts b/src/shared/database-key.ts index 326d4a7..f92634e 100644 --- a/src/shared/database-key.ts +++ b/src/shared/database-key.ts @@ -24,8 +24,36 @@ export interface DatabaseKeyStorageResult { encryptionAvailable: boolean } +export type AccountLoginStatus = 'current' | 'other' | 'unknown' + +export interface WechatAccountCandidate { + id: string + accountRoot: string + directoryName: string + wxid?: string + nickname?: string + avatar?: string + hasSavedDbKey: boolean + loginStatus: AccountLoginStatus + selectedByInput: boolean +} + +export interface AccountDiscoveryResult { + success: boolean + inputKind?: 'root' | 'account' + accounts: WechatAccountCandidate[] + preselectedAccountId?: string + error?: string +} + export interface DatabaseKeyEnvironment { platform: NodeJS.Platform + osVersion: string + appVersion: string + wechatVersion: string + dataStructureVersion: string + dataDirectoryDetected: boolean + diagnosticSummary: string autoDetectSupported: boolean wechatRunning: boolean accountIdentified: boolean diff --git a/src/shared/export-media.ts b/src/shared/export-media.ts new file mode 100644 index 0000000..6e11855 --- /dev/null +++ b/src/shared/export-media.ts @@ -0,0 +1,22 @@ +import type { ExportRequest } from './export' + +export type ImageExportAttempt = { + allowThumbnail: boolean + preferThumbnail: boolean + fallback: boolean +} + +export function getImageExportAttempts( + request: Pick +): ImageExportAttempt[] { + if (request.preferOriginal === false) { + return [{ allowThumbnail: true, preferThumbnail: true, fallback: false }] + } + const attempts: ImageExportAttempt[] = [ + { allowThumbnail: false, preferThumbnail: false, fallback: false } + ] + if (request.fallbackThumbnail !== false) { + attempts.push({ allowThumbnail: true, preferThumbnail: true, fallback: true }) + } + return attempts +} diff --git a/src/shared/export.ts b/src/shared/export.ts index 4d6aad6..5918c34 100644 --- a/src/shared/export.ts +++ b/src/shared/export.ts @@ -23,6 +23,9 @@ export interface ExportRequest { endTime?: number kinds: ExportMessageKind[] includeMedia: boolean + preferOriginal?: boolean + fallbackThumbnail?: boolean + keepMissing?: boolean includeAvatars?: boolean avatarUrls?: Record nameMode?: ExportNameMode diff --git a/tests/component/database-connection.test.tsx b/tests/component/database-connection.test.tsx index 3720915..249dde5 100644 --- a/tests/component/database-connection.test.tsx +++ b/tests/component/database-connection.test.tsx @@ -14,6 +14,35 @@ function renderPage( dbRoot: '', showDbKey: false, isFetching: false, + isConnecting: false, + guideStep: 1 as const, + environment: { + platform: 'win32', + osVersion: 'Windows fixture', + appVersion: 'v2.1.6', + wechatVersion: '4.1.9.57', + dataStructureVersion: '微信 4.x(WCDB)', + dataDirectoryDetected: true, + diagnosticSummary: 'WechatExplorer: v2.1.6', + autoDetectSupported: true, + wechatRunning: true, + accountIdentified: false, + dbConnected: false, + encryptionAvailable: true + }, + accounts: [ + { + id: 'account-a', + accountRoot: 'C:\\fixture\\account-a', + directoryName: 'account-a', + nickname: '脱敏账号 A', + wxid: 'wxid_fixture_a', + hasSavedDbKey: true, + loginStatus: 'unknown' as const, + selectedByInput: true + } + ], + selectedAccountId: 'account-a', status: '', statusKind: 'normal' as const, showMacKeyFaq: false, @@ -21,8 +50,16 @@ function renderPage( onModeChange: vi.fn(), onDbKeyChange: vi.fn(), onDbRootChange: vi.fn(), + onSelectAccount: vi.fn(), + onSelectDbRoot: vi.fn(), onToggleDbKey: vi.fn(), onAutoGetKey: vi.fn(), + onRefreshEnvironment: vi.fn(), + onGuideNext: vi.fn(), + onGuideBack: vi.fn(), + onGuideCancel: vi.fn(), + onValidateConnection: vi.fn(), + onCopyDiagnostics: vi.fn(), onManualConnect: vi.fn(), onPasteKey: vi.fn(), onClearKey: vi.fn(), @@ -52,4 +89,48 @@ describe('DatabaseConnectionPage', () => { expect(onManualConnect).toHaveBeenCalledOnce() expect(screen.getByRole('button', { name: '从剪贴板粘贴并安全保存' })).toBeEnabled() }) + + it('restores directory editing and selection after a failed connection', async () => { + const onDbRootChange = vi.fn() + const onSelectDbRoot = vi.fn() + renderPage({ + dbKey: 'b'.repeat(64), + dbRoot: 'Z:\\missing-wechat-data', + status: '微信数据目录不存在,请重新选择目录', + statusKind: 'error', + onDbRootChange, + onSelectDbRoot + }) + + await userEvent.clear(screen.getByLabelText('微信数据目录')) + await userEvent.type(screen.getByLabelText('微信数据目录'), 'C:\\fixture-account') + await userEvent.click(screen.getByRole('button', { name: '选择目录' })) + + expect(onDbRootChange).toHaveBeenCalled() + expect(onSelectDbRoot).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled() + }) + + it('supports forward, back, cancel and safe diagnostic actions in onboarding', async () => { + const onGuideNext = vi.fn() + const onCopyDiagnostics = vi.fn() + const { rerender, props } = renderPage({ + mode: 'automatic', + guideStep: 1, + onGuideNext, + onCopyDiagnostics + }) + + expect(screen.getByText('4.1.9.57')).toBeVisible() + expect(screen.getByText('微信 4.x(WCDB)')).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: '复制脱敏诊断摘要' })) + await userEvent.click(screen.getByRole('button', { name: '检查完成,继续' })) + expect(onCopyDiagnostics).toHaveBeenCalledOnce() + expect(onGuideNext).toHaveBeenCalledOnce() + + rerender() + expect(screen.getByRole('button', { name: '我已准备好' })).toBeEnabled() + expect(screen.getByRole('button', { name: '返回上一步' })).toBeEnabled() + expect(screen.getByRole('button', { name: '取消并重新检查' })).toBeEnabled() + }) }) diff --git a/tests/e2e/__screenshots__/win32/visual.spec.ts/archive-page.png b/tests/e2e/__screenshots__/win32/visual.spec.ts/archive-page.png index 086df4c..e19371d 100644 Binary files a/tests/e2e/__screenshots__/win32/visual.spec.ts/archive-page.png and b/tests/e2e/__screenshots__/win32/visual.spec.ts/archive-page.png differ diff --git a/tests/e2e/__screenshots__/win32/visual.spec.ts/login-page.png b/tests/e2e/__screenshots__/win32/visual.spec.ts/login-page.png index 537b93e..9f15429 100644 Binary files a/tests/e2e/__screenshots__/win32/visual.spec.ts/login-page.png and b/tests/e2e/__screenshots__/win32/visual.spec.ts/login-page.png differ diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index d7d48b6..7c15b25 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -23,13 +23,8 @@ test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app await fixture.page.getByRole('tab', { name: /高级用户/ }).click() const keyInput = fixture.page.getByLabel('数据库密钥') await keyInput.fill('b'.repeat(64)) - const errorDialog = fixture.page.waitForEvent('dialog') - await fixture.page - .getByRole('button', { name: '连接数据库' }) - .evaluate((element: HTMLButtonElement) => element.click()) - const dialog = await errorDialog - expect(dialog.message()).toContain('数据库密钥无效') - await dialog.dismiss() + await fixture.page.getByRole('button', { name: '连接数据库' }).click() + await expect(fixture.page.getByText('数据库密钥无效')).toBeVisible() await expect(keyInput).toBeVisible() await keyInput.fill('a'.repeat(64)) @@ -40,6 +35,46 @@ test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app } }) +test('P0-01 an invalid directory can be corrected and retried without restarting', async () => { + test.skip(process.platform !== 'win32', 'Manual database directory editing is Windows-only') + const fixture = await launchTestApp({ mode: 'disconnected' }) + try { + await fixture.page.getByRole('tab', { name: /高级用户/ }).click() + await fixture.page.getByLabel('数据库密钥').fill('a'.repeat(64)) + await fixture.page.getByLabel('微信数据目录').fill('Z:\\missing-wechat-data') + await fixture.page.getByRole('button', { name: '连接数据库' }).click() + + await expect(fixture.page.getByText('微信数据目录不存在,请重新选择目录')).toBeVisible() + await expect(fixture.page.getByLabel('微信数据目录')).toBeEditable() + await expect(fixture.page.getByRole('button', { name: '选择目录' })).toBeEnabled() + + await fixture.page.getByRole('button', { name: '选择目录' }).click() + await expect(fixture.page.getByLabel('微信数据目录')).toHaveValue('fixture-account') + await fixture.page.getByRole('button', { name: '连接数据库' }).click() + await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible() + } finally { + await fixture.close() + } +}) + +test('P2-01 P2-02 guided connection exposes safe diagnostics and completes all stages', async () => { + const fixture = await launchTestApp({ mode: 'disconnected' }) + try { + await expect(fixture.page.getByText('4.1.9.57')).toBeVisible() + await expect(fixture.page.getByText('微信 4.x(WCDB)')).toBeVisible() + await expect(fixture.page.getByRole('button', { name: '复制脱敏诊断摘要' })).toBeEnabled() + + await fixture.page.getByRole('button', { name: '检查完成,继续' }).click() + await fixture.page.getByRole('button', { name: '我已准备好' }).click() + await fixture.page.getByRole('button', { name: '开始准备连接组件' }).click() + await expect(fixture.page.getByRole('button', { name: '微信已登录,验证连接' })).toBeEnabled() + await fixture.page.getByRole('button', { name: '微信已登录,验证连接' }).click() + await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible() + } finally { + await fixture.close() + } +}) + test('KEY-03 changing one key does not invalidate archive data or unrelated settings', async () => { const fixture = await launchTestApp() try { @@ -188,6 +223,7 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets', await fixture.page.getByRole('button', { name: '开始生成日报' }).click() await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible() await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() + await fixture.page.getByRole('button', { name: '近 7 天' }).click() const generate = fixture.page.getByRole('button', { name: '开始生成日报' }) await expect(generate).toBeEnabled() await generate.click() @@ -218,6 +254,7 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn await fixture.page.getByRole('button', { name: '日报' }).click() await fixture.page.getByRole('button', { name: '开始生成日报' }).click() await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() + await fixture.page.getByRole('button', { name: '近 7 天' }).click() await fixture.page.getByRole('button', { name: '开始生成日报' }).click() await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible() await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled() diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 438f378..314142c 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -102,7 +102,7 @@ handle('key:getSavedDbKey', () => ({ saved: Boolean(savedKey), encryptionAvailable: true })) -handle('key:saveDbKey', (key) => { +handle('key:saveDbKey', (_accountRoot, key) => { savedKey = String(key || '') return { success: true, key: savedKey, saved: true, encryptionAvailable: true } }) @@ -112,6 +112,12 @@ handle('key:clearSavedDbKey', () => { }) handle('key:getEnvironment', () => ({ platform: process.platform, + osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture', + appVersion: 'v2.1.6', + wechatVersion: '4.1.9.57', + dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.x(WCDB)' : '未检测到', + dataDirectoryDetected: settings.dbRoot === 'fixture-account', + diagnosticSummary: 'WechatExplorer: v2.1.6\n数据目录: 已检测到', autoDetectSupported: true, wechatRunning: true, accountIdentified: connected, @@ -128,12 +134,22 @@ handle('key:autoGetImageKey', () => ({ verified: true })) -handle('db:init', (key) => { +handle('db:init', (key, accountRoot) => { + if (settings.dbRoot === 'Z:\\missing-wechat-data') { + connected = false + return { + success: false, + code: 'ROOT_UNAVAILABLE', + error: '微信数据目录不存在,请重新选择目录', + monitoring: false + } + } if (key !== VALID_KEY) { connected = false return { success: false, error: '数据库密钥无效', monitoring: false } } connected = true + settings.dbRoot = accountRoot || settings.dbRoot return { success: true, monitoring: true } }) handle('db:testConnection', (key) => @@ -339,6 +355,29 @@ handle('image:getStatus', () => ({ ]) ) })) +handle('settings:selectDbRoot', () => ({ canceled: false, path: 'fixture-account' })) +handle('accounts:discover', (inputPath) => + inputPath === 'Z:\\missing-wechat-data' + ? { success: false, accounts: [], error: '微信数据目录不存在,请重新选择目录' } + : { + success: true, + inputKind: 'account', + preselectedAccountId: 'fixture-account-id', + accounts: [ + { + id: 'fixture-account-id', + accountRoot: inputPath || 'fixture-account', + directoryName: 'fixture-account', + wxid: fixture.self.wxid, + nickname: fixture.self.nickname, + avatar: fixture.self.avatar, + hasSavedDbKey: Boolean(savedKey), + loginStatus: connected ? 'current' : 'unknown', + selectedByInput: true + } + ] + } +) handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false })) handle('agent-hub:getLogs', () => []) handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' })) @@ -347,7 +386,6 @@ for (const channel of [ 'export:start', 'export:cancel', 'export:reveal', - 'settings:selectDbRoot', 'settings:openAccountRoot', 'db:reopenWithRoot', 'api:skillStatus', diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts new file mode 100644 index 0000000..90783f4 --- /dev/null +++ b/tests/integration/export-media-flow.test.ts @@ -0,0 +1,173 @@ +import { dirname, join } from 'path' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Message } from '../../src/shared/types' + +const state = vi.hoisted(() => ({ + documents: '', + videoPath: '', + messages: [] as Message[], + imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[] +})) + +vi.mock('electron', () => ({ + app: { getPath: () => state.documents }, + shell: { showItemInFolder: vi.fn() }, + BrowserWindow: class {} +})) +vi.mock('../../src/main/services/chat-service', () => ({ + listMessages: () => structuredClone(state.messages), + getChatDb: () => ({ getWcdb4Client: () => ({}) }), + getContactAvatars: () => ({}) +})) +vi.mock('../../src/main/services/image-key-config-service', () => ({ + ImageKeyConfigService: class { + getConfig(): { aesKey: string; xorKey: string } { + return { aesKey: '0123456789abcdef', xorKey: '0x40' } + } + } +})) +vi.mock('../../src/main/voice-service', () => ({ + VoiceService: class { + async resolveVoice( + _sessionId: string, + localId: number + ): Promise<{ success: boolean; data?: string; error?: string }> { + return localId === 1 + ? { + success: true, + data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=' + } + : { success: false, error: '本地未找到语音数据' } + } + } +})) +vi.mock('../../src/main/image-decrypt-service', () => ({ + ImageDecryptService: class { + findImageFile( + _md5: string, + _datName: string, + options: { allowThumbnail?: boolean; preferThumbnail?: boolean } + ): string { + state.imageLookups.push(options) + return 'fixture-original.dat' + } + decryptImageToBase64WithFallback(): { data: string; filePath: string } { + return { + data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=', + filePath: 'fixture-original.dat' + } + } + isThumbnailFile(): boolean { + return false + } + } +})) +vi.mock('../../src/main/video-asset-service', () => ({ + VideoAssetService: class { + resolve(): { success: boolean; url: string } { + return { success: true, url: 'wxe-media://local/fixture-video' } + } + pathForUrl(): string { + return state.videoPath + } + } +})) +vi.mock('../../src/main/sticker-service', () => ({ + StickerService: class {} +})) + +const message = (overrides: Partial): Message => ({ + id: 'fixture', + from: 'fixture', + type: '普通文本', + datetime: '2026-08-01 10:00:00', + content: '', + isSender: false, + createTime: 1_785_549_600, + ...overrides +}) + +describe('media export flow', () => { + beforeEach(() => { + state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-')) + state.videoPath = join(state.documents, 'fixture.mp4') + writeFileSync( + state.videoPath, + Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex') + ) + state.imageLookups = [] + state.messages = [ + message({ + id: 'voice-ok', + type: '语音', + sessionId: 'fixture-session', + localId: 1, + contentData: { type: 'voice', duration: 1 } + }), + message({ + id: 'voice-missing', + type: '语音', + sessionId: 'fixture-session', + localId: 2, + contentData: { type: 'voice', duration: 1 } + }), + message({ + id: 'image', + type: '图片', + sessionId: 'fixture-session', + contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'fixture.dat' } + }), + message({ + id: 'video', + type: '视频', + contentData: { type: 'video', md5: 'b'.repeat(32) } + }) + ] + }) + + afterEach(() => rmSync(state.documents, { recursive: true, force: true })) + + it('writes playable relative assets, keeps failures, and requests the original image first', async () => { + const { runExport } = await import('../../src/main/export-service') + const progress: unknown[] = [] + const win = { + isDestroyed: () => false, + webContents: { send: (...args: unknown[]) => progress.push(args) } + } + const result = await runExport( + { + jobId: 'fixture-export', + userMd5: 'fixture-user', + name: '脱敏会话', + format: 'html', + outputName: 'fixture', + kinds: ['voice', 'image', 'video'], + includeMedia: true, + preferOriginal: true, + fallbackThumbnail: true, + keepMissing: true + }, + win as never + ) + + expect(result.success).toBe(true) + const html = readFileSync(result.outputPath!, 'utf8') + const outputDir = dirname(result.outputPath!) + expect(readFileSync(join(outputDir, 'voices/voice_1_1.wav')).subarray(0, 4).toString()).toBe( + 'RIFF' + ) + expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe( + 'ftyp' + ) + expect(html).toContain('src="voices/voice_1_1.wav"') + expect(html).toContain('src="media/video_4.mp4"') + expect(html).toContain('语音文件缺失:本地未找到语音数据') + expect(state.imageLookups[0]).toMatchObject({ + allowThumbnail: false, + preferThumbnail: false + }) + expect(progress.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/unit/account-discovery.test.ts b/tests/unit/account-discovery.test.ts new file mode 100644 index 0000000..6e96f08 --- /dev/null +++ b/tests/unit/account-discovery.test.ts @@ -0,0 +1,69 @@ +import fs from 'fs-extra' +import os from 'os' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocked = vi.hoisted(() => ({ + userData: `${process.env.TEMP || process.env.TMP || '.'}/wxe-account-discovery-tests` +})) + +vi.mock('electron', () => ({ + app: { getPath: () => mocked.userData } +})) + +import { discoverAccounts } from '../../src/main/services/account-discovery' + +describe('account discovery', () => { + let root: string + const keyStore = { + getStatus: vi.fn(async (accountRoot: string) => ({ + saved: accountRoot.endsWith('account-b'), + encryptionAvailable: true + })) + } + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'wxe-accounts-')) + await Promise.all( + ['account-a', 'account-b', 'account-c'].map((name) => + fs.ensureDir(path.join(root, name, 'db_storage')) + ) + ) + await fs.ensureDir(path.join(root, 'Backup')) + }) + + afterEach(async () => { + await fs.remove(root) + await fs.remove(mocked.userData) + }) + + it('rejects an invalid Backup directory without continuing', async () => { + const result = await discoverAccounts(path.join(root, 'Backup'), keyStore as never) + expect(result.success).toBe(false) + expect(result.accounts).toEqual([]) + }) + + it('lists every direct account and never preselects one from a root directory', async () => { + const result = await discoverAccounts(root, keyStore as never) + expect(result.success).toBe(true) + expect(result.accounts.map((account) => account.directoryName).sort()).toEqual([ + 'account-a', + 'account-b', + 'account-c' + ]) + expect(result.preselectedAccountId).toBeUndefined() + expect( + result.accounts.find((account) => account.directoryName === 'account-b')?.hasSavedDbKey + ).toBe(true) + }) + + it('preselects a directly selected account directory while retaining its card', async () => { + const accountRoot = path.join(root, 'account-c') + const result = await discoverAccounts(accountRoot, keyStore as never) + expect(result.success).toBe(true) + expect(result.accounts).toHaveLength(1) + expect(result.accounts[0].accountRoot).toBe(accountRoot) + expect(result.preselectedAccountId).toBe(result.accounts[0].id) + expect(result.accounts[0].selectedByInput).toBe(true) + }) +}) diff --git a/tests/unit/connection-diagnostics.test.ts b/tests/unit/connection-diagnostics.test.ts new file mode 100644 index 0000000..3d463a1 --- /dev/null +++ b/tests/unit/connection-diagnostics.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { buildSafeDiagnosticSummary } from '../../src/shared/connection-diagnostics' + +describe('connection diagnostics', () => { + it('contains useful versions and readiness without secrets or full account paths', () => { + const summary = buildSafeDiagnosticSummary({ + platform: 'win32', + osVersion: 'Windows 11 fixture', + appVersion: 'v2.1.6', + wechatVersion: '4.1.9.57', + dataStructureVersion: '微信 4.x(WCDB)', + dataDirectoryDetected: true, + autoDetectSupported: true, + wechatRunning: true, + accountIdentified: true, + dbConnected: false, + encryptionAvailable: true + }) + + expect(summary).toContain('WechatExplorer: v2.1.6') + expect(summary).toContain('微信客户端: 4.1.9.57') + expect(summary).not.toContain('0123456789abcdef') + expect(summary).not.toContain('C:\\Users\\fixture\\xwechat_files\\wxid_secret') + expect(summary).not.toContain('wxid_') + }) +}) diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts new file mode 100644 index 0000000..12dee78 --- /dev/null +++ b/tests/unit/export-media.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { renderExportPage } from '../../src/main/export-html-template' +import { getImageExportAttempts } from '../../src/shared/export-media' +import type { Message } from '../../src/shared/types' + +const baseMessage = (overrides: Partial): Message => ({ + id: 'fixture-message', + from: 'fixture', + type: '文本', + datetime: '2026-08-01 10:00:00', + content: '', + isSender: false, + ...overrides +}) + +describe('export media', () => { + it('always attempts the original before an explicitly enabled thumbnail fallback', () => { + const first = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true }) + const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true }) + + expect(first).toEqual([ + { allowThumbnail: false, preferThumbnail: false, fallback: false }, + { allowThumbnail: true, preferThumbnail: true, fallback: true } + ]) + expect(repeated).toEqual(first) + }) + + it('renders movable relative audio and video assets plus accurate missing-media details', () => { + const html = renderExportPage('脱敏导出', [ + baseMessage({ id: 'voice', type: '语音', voiceDataUrl: 'voices/voice_1.wav' }), + baseMessage({ + id: 'video', + type: '视频', + exportMediaType: 'video', + exportMediaUrl: 'media/video_2.mp4' + }), + baseMessage({ + id: 'missing', + type: '语音', + exportMediaError: '语音文件缺失:本地未找到语音数据' + }) + ]) + + expect(html).toContain( + 'audio class="audio" controls preload="metadata" src="voices/voice_1.wav"' + ) + expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"') + expect(html).toContain('语音文件缺失:本地未找到语音数据') + expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/) + }) + + it('renders explicit and keyboard-accessible lightbox closing controls', () => { + const html = renderExportPage('图片预览', [ + baseMessage({ id: 'image', type: '图片', exportMediaUrl: 'media/image.jpg' }) + ]) + + expect(html).toContain('aria-label="关闭图片预览"') + expect(html).toContain("closeButton.addEventListener('click',closeLightbox)") + expect(html).toContain('if(event.target===box)closeLightbox()') + expect(html).toContain("if(event.key==='Escape')closeLightbox()") + }) +}) diff --git a/tests/unit/message-pagination.test.ts b/tests/unit/message-pagination.test.ts index c6a836f..d8357db 100644 --- a/tests/unit/message-pagination.test.ts +++ b/tests/unit/message-pagination.test.ts @@ -20,4 +20,14 @@ describe('message pagination', () => { ) expect(merged.map((message) => message.id)).toEqual(['oldest', 'overlap', 'latest']) }) + + it('keeps cross-year pages continuous through the earliest fixture record', () => { + const page2025 = [makeMessage('2025', 1_735_689_600), makeMessage('2026', 1_767_225_600)] + const page2017 = [makeMessage('2017', 1_483_228_800), makeMessage('2025', 1_735_689_600)] + + const merged = mergeMessagePages(page2017, page2025) + + expect(merged.map((message) => message.id)).toEqual(['2017', '2025', '2026']) + expect(new Set(merged.map((message) => message.id)).size).toBe(merged.length) + }) }) diff --git a/tests/unit/wcdb-message-shards.test.ts b/tests/unit/wcdb-message-shards.test.ts new file mode 100644 index 0000000..02907ba --- /dev/null +++ b/tests/unit/wcdb-message-shards.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client' + +const message = (id: string, year: number): Wcdb4Message => ({ + mesLocalID: id, + serverId: `server-${id}`, + mesDes: 0, + messageType: '1', + msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)), + msgContent: `fixture-${year}`, + raw: {} +}) + +describe('WCDB message shard pagination', () => { + it('merges cursor and all-store rows for a bounded cross-year page', async () => { + const cursor = vi.fn(async () => [message('2025', 2025)]) + const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)]) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + wcdbGetMessageTableStats: vi.fn(), + wcdbExecQuery: vi.fn(), + getMessagesByCursorAsync: cursor, + getMessagesByTableScanAsync: tableScan + }) as Wcdb4Client + + const result = await client.getMessagesAsync( + 'fixture@chatroom', + undefined, + Math.floor(Date.UTC(2026, 0, 1) / 1000), + { limit: 20 } + ) + + expect(tableScan).toHaveBeenCalledOnce() + expect(result.map((item) => item.msgContent)).toEqual(['fixture-2017', 'fixture-2025']) + }) + + it('reports an unsupported shard query instead of claiming history ended', async () => { + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + getMessagesByCursorAsync: vi.fn(async () => []) + }) as Wcdb4Client + + await expect( + client.getMessagesAsync('fixture@chatroom', undefined, 1_767_225_600, { limit: 20 }) + ).rejects.toThrow('无法检查历史消息分片') + }) +})