mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 完善多账号连接诊断与聊天媒体导出
- 新增微信账号发现、环境诊断和分步数据库连接引导 - 支持按账号安全保存数据库密钥及快速切换账号 - 完善 WCDB 历史消息分片读取和分页状态提示 - 支持导出图片、视频和语音,提供原图优先及缩略图回退 - 更新安装指引、兼容版本说明和相关自动化测试
This commit is contained in:
@@ -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<DatabaseKeyStorageResult> {
|
||||
async load(accountRoot: string): Promise<DatabaseKeyStorageResult> {
|
||||
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<DatabaseKeyStorageResult> {
|
||||
async loadLegacy(): Promise<DatabaseKeyStorageResult> {
|
||||
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<void> {
|
||||
await fs.remove(this.legacyFilePath)
|
||||
}
|
||||
|
||||
async save(accountRoot: string, rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
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) }
|
||||
|
||||
@@ -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 {
|
||||
? `<img src="${safe(m.img)}" alt="">`
|
||||
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
||||
const audio = m.voiceDataUrl
|
||||
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
|
||||
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${safe(m.voiceDataUrl)}"></audio></div>`
|
||||
: ''
|
||||
const mediaStatus = m.exportMediaError
|
||||
? `<div class="media-status">${safe(m.exportMediaError)}</div>`
|
||||
: ''
|
||||
const quote =
|
||||
m.contentData?.type === 'quote'
|
||||
@@ -34,8 +37,8 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
||||
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
||||
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
||||
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
||||
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div>${mediaStatus}</div></div></article>`
|
||||
})
|
||||
.join('')
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image'),closeButton=document.querySelector('#lightbox-close');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const closeLightbox=()=>{box.classList.remove('open');zoom=1;updateZoom()};const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)closeLightbox()});closeButton.addEventListener('click',closeLightbox);document.addEventListener('keydown',event=>{if(event.key==='Escape')closeLightbox()});update()})()</script></body></html>`
|
||||
}
|
||||
|
||||
+134
-43
@@ -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<string>()
|
||||
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({
|
||||
|
||||
+99
-33
@@ -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()
|
||||
|
||||
@@ -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<AccountDiscoveryResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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()
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -938,9 +938,27 @@ export class Wcdb4Client {
|
||||
): Promise<Wcdb4Message[]> {
|
||||
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<Wcdb4Message[]> {
|
||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||
|
||||
let tables: Wcdb4MessageStore[] = []
|
||||
try {
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
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<string, unknown>[] = []
|
||||
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<Record<string, unknown>[]>(
|
||||
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<string, Wcdb4MessageStore>()
|
||||
for (const username of this.uniq(usernames)) {
|
||||
|
||||
Reference in New Issue
Block a user