1 parent
9af1a0ad56
commit
419a53d6ec
27 files changed
+1160
-1246
No files matched your search
@@ -93,7 +93,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -160,7 +159,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -208,7 +206,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -256,7 +253,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -190,7 +189,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -242,7 +240,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -294,7 +291,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -84,7 +83,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -140,7 +138,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -191,7 +188,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
|
||||
+1
-1
@@ -75,4 +75,4 @@ pnpm-lock.yaml
|
||||
wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
Insight
|
||||
/Wedecrypt
|
||||
+17
-4
@@ -2636,13 +2636,24 @@ function registerIpcHandlers() {
|
||||
// 私聊克隆
|
||||
|
||||
|
||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||
ipcMain.handle('image:decrypt', async (_, payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
}) => {
|
||||
return imageDecryptService.decryptImage(payload)
|
||||
})
|
||||
ipcMain.handle('image:resolveCache', async (_, payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) => {
|
||||
@@ -2652,13 +2663,15 @@ function registerIpcHandlers() {
|
||||
'image:resolveCacheBatch',
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
|
||||
) => {
|
||||
const list = Array.isArray(payloads) ? payloads : []
|
||||
const rows = await Promise.all(list.map(async (payload) => {
|
||||
return imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
|
||||
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false
|
||||
})
|
||||
@@ -2670,7 +2683,7 @@ function registerIpcHandlers() {
|
||||
'image:preload',
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => {
|
||||
imagePreloadService.enqueue(payloads || [], options)
|
||||
|
||||
+7
-4
@@ -286,22 +286,25 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 图片解密
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; force?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }) =>
|
||||
ipcRenderer.invoke('image:decrypt', payload),
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:resolveCache', payload),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
|
||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
|
||||
@@ -486,7 +486,7 @@ class ChatService {
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string {
|
||||
private toCodeOnlyMessage(rawMessage?: string | null, fallbackCode = -3999): string {
|
||||
const code = this.extractErrorCode(rawMessage) ?? fallbackCode
|
||||
return `错误码: ${code}`
|
||||
}
|
||||
@@ -7105,13 +7105,23 @@ class ChatService {
|
||||
return { success: false, error: '未找到消息' }
|
||||
}
|
||||
const msg = msgResult.message
|
||||
const rawImageInfo = msg.rawContent ? this.parseImageInfo(msg.rawContent) : {}
|
||||
const imageMd5 = msg.imageMd5 || rawImageInfo.md5
|
||||
const imageDatName = msg.imageDatName
|
||||
|
||||
// 2. 使用 imageDecryptService 解密图片
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
return { success: false, error: '图片缺少 md5/datName,无法定位原文件' }
|
||||
}
|
||||
|
||||
// 2. 使用 imageDecryptService 解密图片(仅使用真实图片标识)
|
||||
const result = await this.imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName || String(msg.localId),
|
||||
force: false
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
force: false,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
|
||||
if (!result.success || !result.localPath) {
|
||||
@@ -8358,7 +8368,6 @@ class ChatService {
|
||||
if (normalized.length === 0) return []
|
||||
|
||||
// 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。
|
||||
// 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。
|
||||
const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900)
|
||||
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512
|
||||
? Math.floor(maxBytesRaw)
|
||||
@@ -9325,7 +9334,7 @@ class ChatService {
|
||||
latest_ts: this.toSafeInt(item?.latest_ts, 0),
|
||||
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
|
||||
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0)
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintPrivateSession) => item.session_id)
|
||||
|
||||
const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
@@ -9344,7 +9353,7 @@ class ChatService {
|
||||
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
|
||||
displayName: String(item?.displayName || '').trim() || undefined,
|
||||
avatarUrl: String(item?.avatarUrl || '').trim() || undefined
|
||||
})).filter((item) => item.session_id && item.start_ts > 0)
|
||||
})).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0)
|
||||
|
||||
const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
@@ -9353,13 +9362,13 @@ class ChatService {
|
||||
sender_username: String(item?.sender_username || '').trim(),
|
||||
message_content: String(item?.message_content || ''),
|
||||
source: String(item?.source || '')
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintMentionItem) => item.session_id)
|
||||
|
||||
const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
count: this.toSafeInt(item?.count, 0),
|
||||
latest_ts: this.toSafeInt(item?.latest_ts, 0)
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintMentionGroup) => item.session_id)
|
||||
|
||||
const diagnostics: MyFootprintDiagnostics = {
|
||||
truncated: Boolean(diagnosticsRaw.truncated),
|
||||
|
||||
@@ -42,7 +42,6 @@ interface ConfigSchema {
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
exportDefaultImageDeepSearchOnMiss: boolean
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
@@ -165,7 +164,6 @@ export class ConfigService {
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 4,
|
||||
exportDefaultImageDeepSearchOnMiss: true,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
|
||||
@@ -108,7 +108,6 @@ export interface ExportOptions {
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
imageDeepSearchOnMiss?: boolean
|
||||
}
|
||||
|
||||
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
||||
@@ -1092,8 +1091,7 @@ class ExportService {
|
||||
private getImageMissingRunCacheKey(
|
||||
sessionId: string,
|
||||
imageMd5?: unknown,
|
||||
imageDatName?: unknown,
|
||||
imageDeepSearchOnMiss = true
|
||||
imageDatName?: unknown
|
||||
): string | null {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
|
||||
@@ -1105,8 +1103,7 @@ class ExportService {
|
||||
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
|
||||
? normalizedImageDatName
|
||||
: ''
|
||||
const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink'
|
||||
return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
|
||||
return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
|
||||
}
|
||||
|
||||
private normalizeEmojiMd5(value: unknown): string | undefined {
|
||||
@@ -3583,7 +3580,6 @@ class ExportService {
|
||||
exportVoiceAsText?: boolean
|
||||
includeVideoPoster?: boolean
|
||||
includeVoiceWithTranscript?: boolean
|
||||
imageDeepSearchOnMiss?: boolean
|
||||
dirCache?: Set<string>
|
||||
}
|
||||
): Promise<MediaExportItem | null> {
|
||||
@@ -3596,8 +3592,7 @@ class ExportService {
|
||||
sessionId,
|
||||
mediaRootDir,
|
||||
mediaRelativePrefix,
|
||||
options.dirCache,
|
||||
options.imageDeepSearchOnMiss !== false
|
||||
options.dirCache
|
||||
)
|
||||
if (result) {
|
||||
}
|
||||
@@ -3654,8 +3649,7 @@ class ExportService {
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>,
|
||||
imageDeepSearchOnMiss = true
|
||||
dirCache?: Set<string>
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||
@@ -3675,8 +3669,7 @@ class ExportService {
|
||||
const missingRunCacheKey = this.getImageMissingRunCacheKey(
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
imageDeepSearchOnMiss
|
||||
imageDatName
|
||||
)
|
||||
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
|
||||
return null
|
||||
@@ -3686,25 +3679,20 @@ class ExportService {
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
force: true, // 导出优先高清,失败再回退缩略图
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: !imageDeepSearchOnMiss
|
||||
hardlinkOnly: true
|
||||
})
|
||||
|
||||
if (!result.success || !result.localPath) {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
if (!imageDeepSearchOnMiss) {
|
||||
console.log(`[Export] 未命中 hardlink(已关闭缺图深度搜索)→ 将显示 [图片] 占位符`)
|
||||
if (missingRunCacheKey) {
|
||||
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||
}
|
||||
return null
|
||||
}
|
||||
// 尝试获取缩略图
|
||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true
|
||||
})
|
||||
if (thumbResult.success && thumbResult.localPath) {
|
||||
@@ -5302,7 +5290,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -5813,7 +5800,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -6685,7 +6671,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -7436,7 +7421,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -7816,7 +7800,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -8240,7 +8223,6 @@ class ExportService {
|
||||
includeVideoPoster: options.format === 'html',
|
||||
includeVoiceWithTranscript: true,
|
||||
exportVideos: options.exportVideos,
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
|
||||
@@ -1208,6 +1208,30 @@ class HttpService {
|
||||
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||
this.ensureDir(sessionDir)
|
||||
|
||||
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
|
||||
if (options.exportImages) {
|
||||
const imageMd5Set = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.localType !== 3) continue
|
||||
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
|
||||
if (imageMd5) {
|
||||
imageMd5Set.add(imageMd5)
|
||||
continue
|
||||
}
|
||||
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
|
||||
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||
imageMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
if (imageMd5Set.size > 0) {
|
||||
try {
|
||||
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||
if (exported) {
|
||||
@@ -1230,27 +1254,50 @@ class HttpService {
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
force: true
|
||||
createTime: msg.createTime,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
let imagePath = result.localPath
|
||||
|
||||
let imagePath = result.success ? result.localPath : undefined
|
||||
if (!imagePath) {
|
||||
try {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
if (cached.success && cached.localPath) {
|
||||
imagePath = cached.localPath
|
||||
}
|
||||
} catch {
|
||||
// ignore resolve failures
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePath) {
|
||||
if (imagePath.startsWith('data:')) {
|
||||
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||
if (base64Match) {
|
||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
if (!base64Match) return null
|
||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
} else if (fs.existsSync(imagePath)) {
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
}
|
||||
|
||||
if (fs.existsSync(imagePath)) {
|
||||
const imageBuffer = fs.readFileSync(imagePath)
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
|
||||
@@ -3,10 +3,11 @@ import { basename, dirname, extname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||
import { writeFile, rm, readdir } from 'fs/promises'
|
||||
import { homedir, tmpdir } from 'os'
|
||||
import crypto from 'crypto'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
|
||||
|
||||
// 获取 ffmpeg-static 的路径
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
@@ -34,7 +35,7 @@ function getStaticFfmpegPath(): string | null {
|
||||
}
|
||||
|
||||
// 方法3: 打包后的路径
|
||||
if (app.isPackaged) {
|
||||
if (app?.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(packedPath)) {
|
||||
@@ -61,24 +62,25 @@ type CachedImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
force?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
}
|
||||
|
||||
export class ImageDecryptService {
|
||||
private configService = new ConfigService()
|
||||
private resolvedCache = new Map<string, string>()
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
private cacheIndexed = false
|
||||
private cacheIndexing: Promise<void> | null = null
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
private nativeLogged = false
|
||||
private datNameScanMissAt = new Map<string, number>()
|
||||
private readonly datNameScanMissTtlMs = 1200
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
@@ -106,7 +108,7 @@ export class ImageDecryptService {
|
||||
|
||||
private writeLog(line: string): void {
|
||||
try {
|
||||
const logDir = join(app.getPath('userData'), 'logs')
|
||||
const logDir = join(this.getUserDataPath(), 'logs')
|
||||
if (!existsSync(logDir)) {
|
||||
mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
@@ -117,9 +119,6 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
if (payload.allowCacheIndex !== false) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
@@ -128,17 +127,21 @@ export class ImageDecryptService {
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(cached)
|
||||
const upgraded = this.isThumbnailPath(cached)
|
||||
? await this.tryPromoteThumbnailCache(payload, key, cached)
|
||||
: null
|
||||
const finalPath = upgraded || cached
|
||||
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(finalPath)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, cached)
|
||||
this.triggerUpdateCheck(payload, key, finalPath)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
@@ -146,22 +149,41 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||
if (existing) {
|
||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, existing)
|
||||
const accountDir = this.resolveCurrentAccountDir()
|
||||
if (accountDir) {
|
||||
const datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: true
|
||||
}
|
||||
)
|
||||
if (datPath) {
|
||||
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false)
|
||||
if (existing) {
|
||||
const upgraded = this.isThumbnailPath(existing)
|
||||
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing)
|
||||
: null
|
||||
const finalPath = upgraded || existing
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath)
|
||||
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(finalPath)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false
|
||||
if (isThumb) {
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, cacheKey, finalPath)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(cacheKey)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
@@ -169,9 +191,6 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
||||
if (!payload.hardlinkOnly) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
@@ -195,25 +214,17 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.hardlinkOnly) {
|
||||
for (const key of cacheKeys) {
|
||||
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
|
||||
if (!existingHd || this.isThumbnailPath(existingHd)) continue
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.force) {
|
||||
const cached = this.resolvedCache.get(cacheKey)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
const upgraded = this.isThumbnailPath(cached)
|
||||
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
|
||||
: null
|
||||
const finalPath = upgraded || cached
|
||||
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
@@ -260,13 +271,13 @@ export class ImageDecryptService {
|
||||
for (const row of result.rows) {
|
||||
const md5 = String(row?.md5 || '').trim().toLowerCase()
|
||||
if (!md5) continue
|
||||
const fullPath = String(row?.data?.full_path || '').trim()
|
||||
if (!fullPath || !existsSync(fullPath)) continue
|
||||
this.cacheDatPath(accountDir, md5, fullPath)
|
||||
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
|
||||
if (fileName) {
|
||||
this.cacheDatPath(accountDir, fileName, fullPath)
|
||||
}
|
||||
const fullPath = String(row?.data?.full_path || '').trim()
|
||||
if (!fileName || !fullPath) continue
|
||||
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
|
||||
if (!selectedPath || !existsSync(selectedPath)) continue
|
||||
this.cacheDatPath(accountDir, md5, selectedPath)
|
||||
this.cacheDatPath(accountDir, fileName, selectedPath)
|
||||
}
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
@@ -307,6 +318,7 @@ export class ImageDecryptService {
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: false,
|
||||
skipResolvedCache: true,
|
||||
@@ -319,6 +331,7 @@ export class ImageDecryptService {
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: true,
|
||||
@@ -339,6 +352,7 @@ export class ImageDecryptService {
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: false,
|
||||
@@ -368,21 +382,18 @@ export class ImageDecryptService {
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
|
||||
// 查找已缓存的解密文件(hardlink-only 模式下跳过全缓存目录扫描)
|
||||
if (!payload.hardlinkOnly) {
|
||||
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
||||
const isHd = this.isHdPath(existing)
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
const preferHdCache = Boolean(payload.force && !fallbackToThumbnail)
|
||||
const existingFast = this.findCachedOutputByDatPath(datPath, payload.sessionId, preferHdCache)
|
||||
if (existingFast) {
|
||||
this.logInfo('找到已解密文件(按DAT快速命中)', { existing: existingFast, isHd: this.isHdPath(existingFast) })
|
||||
const isHd = this.isHdPath(existingFast)
|
||||
if (!(payload.force && !isHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingFast)
|
||||
const localPath = this.resolveLocalPathForPayload(existingFast, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existingFast)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingFast, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,14 +418,20 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
const aesKeyRaw = imageKeys.aesKey
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
|
||||
const aesKeyForNative = aesKeyText || undefined
|
||||
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
|
||||
if (!nativeResult) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用')
|
||||
return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置' }
|
||||
}
|
||||
let decrypted: Buffer = nativeResult.data
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
|
||||
|
||||
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||
// 统一走原有 wxgf/ffmpeg 流程,确保行为与历史版本一致
|
||||
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||
decrypted = wxgfResult.data
|
||||
|
||||
@@ -487,6 +504,13 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveCurrentAccountDir(): string | null {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) return null
|
||||
return this.resolveAccountDir(dbPath, wxid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解密后的缓存目录(用于查找 hardlink.db)
|
||||
*/
|
||||
@@ -549,6 +573,7 @@ export class ImageDecryptService {
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
sessionId?: string,
|
||||
createTime?: number,
|
||||
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
|
||||
): Promise<string | null> {
|
||||
const allowThumbnail = options?.allowThumbnail ?? true
|
||||
@@ -557,198 +582,87 @@ export class ImageDecryptService {
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime,
|
||||
allowThumbnail,
|
||||
skipResolvedCache,
|
||||
hardlinkOnly
|
||||
})
|
||||
|
||||
if (!skipResolvedCache) {
|
||||
if (imageMd5) {
|
||||
const cached = this.resolvedCache.get(imageMd5)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
this.cacheDatPath(accountDir, imageMd5, preferred)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred)
|
||||
return preferred
|
||||
}
|
||||
const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
|
||||
if (lookupMd5s.length === 0) {
|
||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, imageDatName, sessionId, createTime, allowThumbnail)
|
||||
if (packedDatFallback) {
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
|
||||
const normalizedFileName = basename(packedDatFallback).toLowerCase()
|
||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
|
||||
this.logInfo('[ImageDecrypt] datName fallback hit (no hardlink md5)', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
selectedPath: packedDatFallback
|
||||
})
|
||||
return packedDatFallback
|
||||
}
|
||||
if (imageDatName) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
this.cacheDatPath(accountDir, imageDatName, preferred)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred)
|
||||
return preferred
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
||||
if (!hardlinkOnly && allowThumbnail && imageMd5) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||
if (res) return res
|
||||
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
|
||||
const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (datNameRes) return datNameRes
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||
if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// 优先通过 hardlink.db 查询
|
||||
if (imageMd5) {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
|
||||
if (hardlinkPath) {
|
||||
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
// 没找到高清图,返回 null(不进行全局搜索)
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
|
||||
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
|
||||
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
|
||||
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||
if (fallbackPath) {
|
||||
const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||
if (hardlinkPath) {
|
||||
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
|
||||
if (hardlinkOnly) {
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (no hardlink md5)', { imageMd5, imageDatName })
|
||||
return null
|
||||
}
|
||||
|
||||
const searchNames = Array.from(
|
||||
new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (searchNames.length === 0) return null
|
||||
|
||||
if (!skipResolvedCache) {
|
||||
for (const searchName of searchNames) {
|
||||
const cached = this.resolvedCache.get(searchName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
const cacheCandidates = Array.from(new Set([
|
||||
...lookupMd5s,
|
||||
String(imageMd5 || '').trim().toLowerCase(),
|
||||
String(imageDatName || '').trim().toLowerCase()
|
||||
].filter(Boolean)))
|
||||
for (const cacheKey of cacheCandidates) {
|
||||
const scopedKey = `${accountDir}|${cacheKey}`
|
||||
const cached = this.resolvedCache.get(scopedKey)
|
||||
if (!cached) continue
|
||||
if (!existsSync(cached)) continue
|
||||
if (!allowThumbnail && this.isThumbnailPath(cached)) continue
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
|
||||
this.cacheDatPath(accountDir, searchName, datPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
|
||||
return datPath
|
||||
}
|
||||
for (const lookupMd5 of lookupMd5s) {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup', { lookupMd5, sessionId, hardlinkOnly })
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, lookupMd5, sessionId)
|
||||
if (!hardlinkPath) continue
|
||||
if (!allowThumbnail && this.isThumbnailPath(hardlinkPath)) continue
|
||||
|
||||
this.cacheDatPath(accountDir, lookupMd5, hardlinkPath)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||
const normalizedFileName = basename(hardlinkPath).toLowerCase()
|
||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath)
|
||||
return hardlinkPath
|
||||
}
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const normalized = this.normalizeDatBase(searchName)
|
||||
if (normalized !== searchName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath)
|
||||
this.cacheDatPath(accountDir, searchName, normalizedPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath)
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, imageDatName, sessionId, createTime, allowThumbnail)
|
||||
if (packedDatFallback) {
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
|
||||
const normalizedFileName = basename(packedDatFallback).toLowerCase()
|
||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
|
||||
this.logInfo('[ImageDecrypt] datName fallback hit (hardlink miss)', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
lookupMd5s,
|
||||
selectedPath: packedDatFallback
|
||||
})
|
||||
return packedDatFallback
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
|
||||
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink + datName fallback)', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
lookupMd5s
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
private async resolveThumbnailDatPath(
|
||||
accountDir: string,
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
sessionId?: string
|
||||
): Promise<string | null> {
|
||||
if (imageMd5) {
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
|
||||
if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath
|
||||
}
|
||||
|
||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||
if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath
|
||||
}
|
||||
|
||||
if (!imageDatName) return null
|
||||
return this.searchDatFile(accountDir, imageDatName, true, true)
|
||||
}
|
||||
|
||||
private async checkHasUpdate(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
cacheKey: string,
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number },
|
||||
_cacheKey: string,
|
||||
cachedPath: string
|
||||
): Promise<boolean> {
|
||||
if (!cachedPath || !existsSync(cachedPath)) return false
|
||||
@@ -760,37 +674,94 @@ export class ImageDecryptService {
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return false
|
||||
|
||||
const quickDir = this.getCachedDatDir(accountDir, payload.imageDatName, payload.imageMd5)
|
||||
if (quickDir) {
|
||||
const baseName = payload.imageDatName || payload.imageMd5 || cacheKey
|
||||
const candidate = this.findNonThumbnailVariantInDir(quickDir, baseName)
|
||||
if (candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const thumbPath = await this.resolveThumbnailDatPath(
|
||||
const hdPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true }
|
||||
)
|
||||
if (thumbPath) {
|
||||
const baseName = payload.imageDatName || payload.imageMd5 || cacheKey
|
||||
const candidate = this.findNonThumbnailVariantInDir(dirname(thumbPath), baseName)
|
||||
if (candidate) {
|
||||
return true
|
||||
}
|
||||
const searchHit = await this.searchDatFileInDir(dirname(thumbPath), baseName, false)
|
||||
if (searchHit && this.isNonThumbnailVariantDat(searchHit)) {
|
||||
return true
|
||||
}
|
||||
return Boolean(hdPath)
|
||||
}
|
||||
|
||||
private async tryPromoteThumbnailCache(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean },
|
||||
cacheKey: string,
|
||||
cachedPath: string
|
||||
): Promise<string | null> {
|
||||
if (!cachedPath || !existsSync(cachedPath)) return null
|
||||
if (!this.isImageFile(cachedPath)) return null
|
||||
if (!this.isThumbnailPath(cachedPath)) return null
|
||||
|
||||
const accountDir = this.resolveCurrentAccountDir()
|
||||
if (!accountDir) return null
|
||||
|
||||
const hdDatPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true }
|
||||
)
|
||||
if (!hdDatPath) return null
|
||||
|
||||
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true)
|
||||
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && !this.isThumbnailPath(existingHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
this.removeThumbnailCacheFile(cachedPath, existingHd)
|
||||
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
|
||||
cacheKey,
|
||||
oldPath: cachedPath,
|
||||
newPath: existingHd,
|
||||
mode: 'existing'
|
||||
})
|
||||
return existingHd
|
||||
}
|
||||
return false
|
||||
|
||||
const upgraded = await this.decryptImage({
|
||||
sessionId: payload.sessionId,
|
||||
imageMd5: payload.imageMd5,
|
||||
imageDatName: payload.imageDatName,
|
||||
createTime: payload.createTime,
|
||||
preferFilePath: true,
|
||||
force: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true
|
||||
})
|
||||
if (!upgraded.success) return null
|
||||
|
||||
const cachedResult = this.resolvedCache.get(cacheKey)
|
||||
const upgradedPath = (cachedResult && existsSync(cachedResult))
|
||||
? cachedResult
|
||||
: String(upgraded.localPath || '').trim()
|
||||
if (!upgradedPath || !existsSync(upgradedPath)) return null
|
||||
if (!this.isImageFile(upgradedPath) || this.isThumbnailPath(upgradedPath)) return null
|
||||
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
this.removeThumbnailCacheFile(cachedPath, upgradedPath)
|
||||
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
|
||||
cacheKey,
|
||||
oldPath: cachedPath,
|
||||
newPath: upgradedPath,
|
||||
mode: 're-decrypt'
|
||||
})
|
||||
return upgradedPath
|
||||
}
|
||||
|
||||
private removeThumbnailCacheFile(oldPath: string, keepPath?: string): void {
|
||||
if (!oldPath) return
|
||||
if (keepPath && oldPath === keepPath) return
|
||||
if (!existsSync(oldPath)) return
|
||||
if (!this.isThumbnailPath(oldPath)) return
|
||||
void rm(oldPath, { force: true }).catch(() => { })
|
||||
}
|
||||
|
||||
private triggerUpdateCheck(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number },
|
||||
cacheKey: string,
|
||||
cachedPath: string
|
||||
): void {
|
||||
@@ -804,50 +775,429 @@ export class ImageDecryptService {
|
||||
|
||||
|
||||
|
||||
private resolveHardlinkDbPath(accountDir: string): string | null {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const cacheDir = wxid ? this.getDecryptedCacheDir(wxid) : null
|
||||
const candidates = [
|
||||
join(accountDir, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||
join(accountDir, 'hardlink.db'),
|
||||
cacheDir ? join(cacheDir, 'hardlink.db') : null
|
||||
].filter(Boolean) as string[]
|
||||
this.logInfo('[ImageDecrypt] hardlink db probe', { accountDir, cacheDir, candidates })
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] {
|
||||
const keys: string[] = []
|
||||
const pushMd5 = (value?: string) => {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (!normalized) return
|
||||
if (!this.looksLikeMd5(normalized)) return
|
||||
if (!keys.includes(normalized)) keys.push(normalized)
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink db missing', { accountDir, cacheDir, candidates })
|
||||
|
||||
pushMd5(imageMd5)
|
||||
|
||||
const datNameRaw = String(imageDatName || '').trim().toLowerCase()
|
||||
if (!datNameRaw) return keys
|
||||
pushMd5(datNameRaw)
|
||||
const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw
|
||||
pushMd5(datNameNoExt)
|
||||
pushMd5(this.normalizeDatBase(datNameNoExt))
|
||||
return keys
|
||||
}
|
||||
|
||||
private resolveDatPathFromParsedDatName(
|
||||
accountDir: string,
|
||||
imageDatName?: string,
|
||||
sessionId?: string,
|
||||
createTime?: number,
|
||||
allowThumbnail = true
|
||||
): string | null {
|
||||
const datNameRaw = String(imageDatName || '').trim().toLowerCase()
|
||||
if (!datNameRaw) return null
|
||||
const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw
|
||||
const baseMd5 = this.normalizeDatBase(datNameNoExt)
|
||||
if (!this.looksLikeMd5(baseMd5)) return null
|
||||
|
||||
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
|
||||
const missKey = `${accountDir}|scan|${String(sessionId || '').trim()}|${monthKey}|${baseMd5}|${allowThumbnail ? 'all' : 'hd'}`
|
||||
const lastMiss = this.datNameScanMissAt.get(missKey) || 0
|
||||
if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionMonthCandidates = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime)
|
||||
if (sessionMonthCandidates.length > 0) {
|
||||
const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5)
|
||||
for (const candidatePath of orderedSessionMonth) {
|
||||
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
|
||||
this.datNameScanMissAt.delete(missKey)
|
||||
this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', {
|
||||
accountDir,
|
||||
sessionId,
|
||||
imageDatName: datNameRaw,
|
||||
createTime,
|
||||
monthKey,
|
||||
baseMd5,
|
||||
allowThumbnail,
|
||||
selectedPath: candidatePath
|
||||
})
|
||||
return candidatePath
|
||||
}
|
||||
}
|
||||
|
||||
const hasPreciseContext = Boolean(String(sessionId || '').trim() && monthKey)
|
||||
if (hasPreciseContext) {
|
||||
this.datNameScanMissAt.set(missKey, Date.now())
|
||||
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
|
||||
accountDir,
|
||||
sessionId,
|
||||
imageDatName: datNameRaw,
|
||||
createTime,
|
||||
monthKey,
|
||||
baseMd5,
|
||||
allowThumbnail
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const candidates = this.collectDatCandidatesFromAccountDir(accountDir, baseMd5)
|
||||
if (candidates.length === 0) {
|
||||
this.datNameScanMissAt.set(missKey, Date.now())
|
||||
this.logInfo('[ImageDecrypt] datName fallback scan miss', {
|
||||
accountDir,
|
||||
sessionId,
|
||||
imageDatName: datNameRaw,
|
||||
createTime,
|
||||
monthKey,
|
||||
baseMd5,
|
||||
allowThumbnail
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const ordered = this.sortDatCandidatePaths(candidates, baseMd5)
|
||||
for (const candidatePath of ordered) {
|
||||
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
|
||||
this.datNameScanMissAt.delete(missKey)
|
||||
this.logInfo('[ImageDecrypt] datName fallback selected', {
|
||||
accountDir,
|
||||
sessionId,
|
||||
imageDatName: datNameRaw,
|
||||
createTime,
|
||||
monthKey,
|
||||
baseMd5,
|
||||
allowThumbnail,
|
||||
selectedPath: candidatePath
|
||||
})
|
||||
return candidatePath
|
||||
}
|
||||
|
||||
this.datNameScanMissAt.set(missKey, Date.now())
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveYearMonthFromCreateTime(createTime?: number): string {
|
||||
const raw = Number(createTime)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return ''
|
||||
const ts = raw > 1e12 ? raw : raw * 1000
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
private collectDatCandidatesFromSessionMonth(
|
||||
accountDir: string,
|
||||
baseMd5: string,
|
||||
sessionId?: string,
|
||||
createTime?: number
|
||||
): string[] {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
|
||||
if (!normalizedSessionId || !monthKey) return []
|
||||
|
||||
const attachRoots = this.getAttachScanRoots(accountDir)
|
||||
const cacheRoots = this.getMessageCacheScanRoots(accountDir)
|
||||
const sessionDirs = this.getAttachSessionDirCandidates(normalizedSessionId)
|
||||
const candidates = new Set<string>()
|
||||
const budget = { remaining: 600 }
|
||||
const targetDirs: Array<{ dir: string; depth: number }> = []
|
||||
|
||||
for (const root of attachRoots) {
|
||||
for (const sessionDir of sessionDirs) {
|
||||
targetDirs.push({ dir: join(root, sessionDir, monthKey), depth: 2 })
|
||||
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Img'), depth: 1 })
|
||||
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Image'), depth: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of cacheRoots) {
|
||||
for (const sessionDir of sessionDirs) {
|
||||
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir, 'Bubble'), depth: 1 })
|
||||
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir), depth: 2 })
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targetDirs) {
|
||||
if (budget.remaining <= 0) break
|
||||
this.scanDatCandidatesUnderRoot(target.dir, baseMd5, target.depth, candidates, budget)
|
||||
}
|
||||
|
||||
return Array.from(candidates)
|
||||
}
|
||||
|
||||
private getAttachScanRoots(accountDir: string): string[] {
|
||||
const roots: string[] = []
|
||||
const push = (value: string) => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return
|
||||
if (!roots.includes(normalized)) roots.push(normalized)
|
||||
}
|
||||
|
||||
push(join(accountDir, 'msg', 'attach'))
|
||||
push(join(accountDir, 'attach'))
|
||||
const parent = dirname(accountDir)
|
||||
if (parent && parent !== accountDir) {
|
||||
push(join(parent, 'msg', 'attach'))
|
||||
push(join(parent, 'attach'))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
private getMessageCacheScanRoots(accountDir: string): string[] {
|
||||
const roots: string[] = []
|
||||
const push = (value: string) => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return
|
||||
if (!roots.includes(normalized)) roots.push(normalized)
|
||||
}
|
||||
|
||||
push(join(accountDir, 'cache'))
|
||||
const parent = dirname(accountDir)
|
||||
if (parent && parent !== accountDir) {
|
||||
push(join(parent, 'cache'))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
private getAttachSessionDirCandidates(sessionId: string): string[] {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
if (!normalized) return []
|
||||
const lower = normalized.toLowerCase()
|
||||
const cleaned = this.cleanAccountDirName(normalized)
|
||||
const inputs = Array.from(new Set([normalized, lower, cleaned, cleaned.toLowerCase()].filter(Boolean)))
|
||||
const results: string[] = []
|
||||
const push = (value: string) => {
|
||||
if (!value) return
|
||||
if (!results.includes(value)) results.push(value)
|
||||
}
|
||||
|
||||
for (const item of inputs) {
|
||||
push(item)
|
||||
const md5 = crypto.createHash('md5').update(item).digest('hex').toLowerCase()
|
||||
push(md5)
|
||||
push(md5.slice(0, 16))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private collectDatCandidatesFromAccountDir(accountDir: string, baseMd5: string): string[] {
|
||||
const roots = this.getDatScanRoots(accountDir)
|
||||
const candidates = new Set<string>()
|
||||
const budget = { remaining: 1400 }
|
||||
|
||||
for (const item of roots) {
|
||||
if (budget.remaining <= 0) break
|
||||
this.scanDatCandidatesUnderRoot(item.root, baseMd5, item.maxDepth, candidates, budget)
|
||||
}
|
||||
|
||||
if (candidates.size === 0 && budget.remaining <= 0) {
|
||||
this.logInfo('[ImageDecrypt] datName fallback budget exhausted', {
|
||||
accountDir,
|
||||
baseMd5,
|
||||
roots: roots.map((item) => item.root)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(candidates)
|
||||
}
|
||||
|
||||
private getDatScanRoots(accountDir: string): Array<{ root: string; maxDepth: number }> {
|
||||
const roots: Array<{ root: string; maxDepth: number }> = []
|
||||
const push = (root: string, maxDepth: number) => {
|
||||
const normalized = String(root || '').trim()
|
||||
if (!normalized) return
|
||||
if (roots.some((item) => item.root === normalized)) return
|
||||
roots.push({ root: normalized, maxDepth })
|
||||
}
|
||||
|
||||
push(join(accountDir, 'attach'), 4)
|
||||
push(join(accountDir, 'msg', 'attach'), 4)
|
||||
push(join(accountDir, 'FileStorage', 'Image'), 3)
|
||||
push(join(accountDir, 'FileStorage', 'Image2'), 3)
|
||||
push(join(accountDir, 'FileStorage', 'MsgImg'), 3)
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
private scanDatCandidatesUnderRoot(
|
||||
rootDir: string,
|
||||
baseMd5: string,
|
||||
maxDepth: number,
|
||||
out: Set<string>,
|
||||
budget: { remaining: number }
|
||||
): void {
|
||||
if (!rootDir || maxDepth < 0 || budget.remaining <= 0) return
|
||||
if (!existsSync(rootDir) || !this.isDirectory(rootDir)) return
|
||||
|
||||
const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }]
|
||||
while (stack.length > 0 && budget.remaining > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
budget.remaining -= 1
|
||||
|
||||
let entries: Array<{ name: string; isFile: () => boolean; isDirectory: () => boolean }>
|
||||
try {
|
||||
entries = readdirSync(current.dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
const name = String(entry.name || '')
|
||||
if (!this.isHardlinkCandidateName(name, baseMd5)) continue
|
||||
const fullPath = join(current.dir, name)
|
||||
if (existsSync(fullPath)) out.add(fullPath)
|
||||
}
|
||||
|
||||
if (current.depth >= maxDepth) continue
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const name = String(entry.name || '')
|
||||
if (!name || name === '.' || name === '..') continue
|
||||
if (name.startsWith('.')) continue
|
||||
stack.push({ dir: join(current.dir, name), depth: current.depth + 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sortDatCandidatePaths(paths: string[], baseMd5: string): string[] {
|
||||
const list = Array.from(new Set(paths.filter(Boolean)))
|
||||
list.sort((a, b) => {
|
||||
const nameA = basename(a).toLowerCase()
|
||||
const nameB = basename(b).toLowerCase()
|
||||
const priorityA = this.getHardlinkCandidatePriority(nameA, baseMd5)
|
||||
const priorityB = this.getHardlinkCandidatePriority(nameB, baseMd5)
|
||||
if (priorityA !== priorityB) return priorityA - priorityB
|
||||
|
||||
let mtimeA = 0
|
||||
let mtimeB = 0
|
||||
try {
|
||||
mtimeA = statSync(a).mtimeMs
|
||||
} catch { }
|
||||
try {
|
||||
mtimeB = statSync(b).mtimeMs
|
||||
} catch { }
|
||||
if (mtimeA !== mtimeB) return mtimeB - mtimeA
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
private isPlainMd5DatName(fileName: string): boolean {
|
||||
const lower = String(fileName || '').trim().toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
const base = lower.slice(0, -4)
|
||||
return this.looksLikeMd5(base)
|
||||
}
|
||||
|
||||
private isHardlinkCandidateName(fileName: string, baseMd5: string): boolean {
|
||||
const lower = String(fileName || '').trim().toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
const base = lower.slice(0, -4)
|
||||
if (base === baseMd5) return true
|
||||
if (base.startsWith(`${baseMd5}_`) || base.startsWith(`${baseMd5}.`)) return true
|
||||
if (base.length === baseMd5.length + 1 && base.startsWith(baseMd5)) return true
|
||||
return this.normalizeDatBase(base) === baseMd5
|
||||
}
|
||||
|
||||
private getHardlinkCandidatePriority(fileName: string, baseMd5: string): number {
|
||||
const lower = String(fileName || '').trim().toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return 999
|
||||
const base = lower.slice(0, -4)
|
||||
|
||||
// 无后缀 DAT 最后兜底;优先尝试变体 DAT。
|
||||
if (base === baseMd5) return 20
|
||||
// _t / .t / _thumb 等缩略图 DAT 仅作次级回退。
|
||||
if (this.isThumbnailDat(lower)) return 10
|
||||
// 其他非缩略图变体优先。
|
||||
return 0
|
||||
}
|
||||
|
||||
private resolveHardlinkDatVariants(fullPath: string, baseMd5: string): string[] {
|
||||
const dirPath = dirname(fullPath)
|
||||
try {
|
||||
const entries = readdirSync(dirPath, { withFileTypes: true })
|
||||
const candidates = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => this.isHardlinkCandidateName(name, baseMd5))
|
||||
.map((name) => join(dirPath, name))
|
||||
.filter((candidatePath) => existsSync(candidatePath))
|
||||
return this.sortDatCandidatePaths(candidates, baseMd5)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeHardlinkDatPathByFileName(fullPath: string, fileName: string): string {
|
||||
const normalizedPath = String(fullPath || '').trim()
|
||||
const normalizedFileName = String(fileName || '').trim().toLowerCase()
|
||||
if (!normalizedPath || !normalizedFileName.endsWith('.dat')) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
// hardlink 记录到具体后缀时(如 _b/.b/_t),直接按记录路径解密。
|
||||
if (!this.isPlainMd5DatName(normalizedFileName)) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
const base = normalizedFileName.slice(0, -4)
|
||||
if (!this.looksLikeMd5(base)) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
const candidates = this.resolveHardlinkDatVariants(normalizedPath, base)
|
||||
if (candidates.length > 0) {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
|
||||
try {
|
||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||
if (!this.looksLikeMd5(normalizedMd5)) return null
|
||||
const ready = await this.ensureWcdbReady()
|
||||
if (!ready) {
|
||||
this.logInfo('[ImageDecrypt] hardlink db not ready')
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
|
||||
const resolveResult = await wcdbService.resolveImageHardlink(normalizedMd5, accountDir)
|
||||
if (!resolveResult.success || !resolveResult.data) return null
|
||||
const fileName = String(resolveResult.data.file_name || '').trim()
|
||||
const fullPath = String(resolveResult.data.full_path || '').trim()
|
||||
if (!fileName) return null
|
||||
if (!fileName || !fullPath) return null
|
||||
|
||||
const lowerFileName = String(fileName).toLowerCase()
|
||||
if (lowerFileName.endsWith('.dat')) {
|
||||
const baseLower = lowerFileName.slice(0, -4)
|
||||
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
|
||||
const normalizedBase = this.normalizeDatBase(lowerFileName.slice(0, -4))
|
||||
if (!this.looksLikeMd5(normalizedBase)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
|
||||
return fullPath
|
||||
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
|
||||
if (existsSync(selectedPath)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { md5: normalizedMd5, fileName, fullPath, selectedPath })
|
||||
return selectedPath
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { md5: normalizedMd5, fileName, fullPath, selectedPath })
|
||||
return null
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -879,182 +1229,6 @@ export class ImageDecryptService {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
private async searchDatFile(
|
||||
accountDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true,
|
||||
thumbOnly = false
|
||||
): Promise<string | null> {
|
||||
const key = `${accountDir}|${datName}`
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
}
|
||||
|
||||
const root = join(accountDir, 'msg', 'attach')
|
||||
if (!existsSync(root)) return null
|
||||
|
||||
// 优化1:快速概率性查找
|
||||
// 包含:1. 基于文件名的前缀猜测 (旧版)
|
||||
// 2. 基于日期的最近月份扫描 (新版无索引时)
|
||||
const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail)
|
||||
if (fastHit) {
|
||||
this.resolvedCache.set(key, fastHit)
|
||||
return fastHit
|
||||
}
|
||||
|
||||
// 优化2:兜底扫描 (异步非阻塞)
|
||||
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
|
||||
if (found) {
|
||||
this.resolvedCache.set(key, found)
|
||||
return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于文件名的哈希特征猜测可能的路径
|
||||
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||
*/
|
||||
private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise<string | null> {
|
||||
const { promises: fs } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
try {
|
||||
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
|
||||
const lowerName = datName.toLowerCase()
|
||||
const baseName = this.normalizeDatBase(lowerName)
|
||||
const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail)
|
||||
|
||||
const candidates: string[] = []
|
||||
if (/^[a-f0-9]{32}$/.test(baseName)) {
|
||||
const dir1 = baseName.substring(0, 2)
|
||||
const dir2 = baseName.substring(2, 4)
|
||||
for (const targetName of targetNames) {
|
||||
candidates.push(
|
||||
join(root, dir1, dir2, targetName),
|
||||
join(root, dir1, dir2, 'Img', targetName),
|
||||
join(root, dir1, dir2, 'mg', targetName),
|
||||
join(root, dir1, dir2, 'Image', targetName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
await fs.access(path)
|
||||
return path
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// --- 策略 B: 新版 Session 哈希路径猜测 ---
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
const sessionDirs = entries
|
||||
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
|
||||
.map((e: any) => e.name)
|
||||
|
||||
if (sessionDirs.length === 0) return null
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
// Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months".
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
months.push(mStr)
|
||||
}
|
||||
|
||||
const batchSize = 20
|
||||
for (let i = 0; i < sessionDirs.length; i += batchSize) {
|
||||
const batch = sessionDirs.slice(i, i + batchSize)
|
||||
const tasks = batch.map(async (sessDir: string) => {
|
||||
for (const month of months) {
|
||||
const subDirs = ['Img', 'Image']
|
||||
for (const sub of subDirs) {
|
||||
const dirPath = join(root, sessDir, month, sub)
|
||||
try { await fs.access(dirPath) } catch { continue }
|
||||
for (const name of targetNames) {
|
||||
const p = join(dirPath, name)
|
||||
try { await fs.access(p); return p } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const results = await Promise.all(tasks)
|
||||
const hit = results.find(r => r !== null)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在同一目录下查找高清图变体
|
||||
* 优先 `_h`,再回退其他非缩略图变体
|
||||
*/
|
||||
private findHdVariantInSameDir(thumbPath: string): string | null {
|
||||
try {
|
||||
const dir = dirname(thumbPath)
|
||||
const fileName = basename(thumbPath)
|
||||
return this.findPreferredDatVariantInDir(dir, fileName, false)
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
private async searchDatFileInDir(
|
||||
dirPath: string,
|
||||
datName: string,
|
||||
allowThumbnail = true
|
||||
): Promise<string | null> {
|
||||
if (!existsSync(dirPath)) return null
|
||||
return await this.walkForDatInWorker(dirPath, datName.toLowerCase(), 3, allowThumbnail, false)
|
||||
}
|
||||
|
||||
private async walkForDatInWorker(
|
||||
root: string,
|
||||
datName: string,
|
||||
maxDepth = 4,
|
||||
allowThumbnail = true,
|
||||
thumbOnly = false
|
||||
): Promise<string | null> {
|
||||
const workerPath = join(__dirname, 'imageSearchWorker.js')
|
||||
return await new Promise((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: { root, datName, maxDepth, allowThumbnail, thumbOnly }
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
worker.removeAllListeners()
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'done') {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve(msg.path || null)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'error') {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', () => {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
@@ -1069,77 +1243,6 @@ export class ImageDecryptService {
|
||||
return lower
|
||||
}
|
||||
|
||||
private getDatVariantPriority(name: string): number {
|
||||
const lower = name.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!this.hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (this.isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
}
|
||||
|
||||
private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] {
|
||||
if (!baseName) return []
|
||||
const names = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}_hd.dat`,
|
||||
`${baseName}.hd.dat`,
|
||||
`${baseName}_b.dat`,
|
||||
`${baseName}.b.dat`,
|
||||
`${baseName}_w.dat`,
|
||||
`${baseName}.w.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_c.dat`,
|
||||
`${baseName}.c.dat`
|
||||
]
|
||||
if (allowThumbnail) {
|
||||
names.push(
|
||||
`${baseName}_thumb.dat`,
|
||||
`${baseName}.thumb.dat`,
|
||||
`${baseName}_t.dat`,
|
||||
`${baseName}.t.dat`
|
||||
)
|
||||
}
|
||||
return Array.from(new Set(names))
|
||||
}
|
||||
|
||||
private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dirPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const target = this.normalizeDatBase(baseName.toLowerCase())
|
||||
let bestPath: string | null = null
|
||||
let bestScore = Number.NEGATIVE_INFINITY
|
||||
for (const entry of entries) {
|
||||
const lower = entry.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
if (!allowThumbnail && this.isThumbnailDat(lower)) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||
const score = this.getDatVariantPriority(lower)
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestPath = join(dirPath, entry)
|
||||
}
|
||||
}
|
||||
return bestPath
|
||||
}
|
||||
|
||||
private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string {
|
||||
const lower = datPath.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return datPath
|
||||
const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail)
|
||||
return preferred || datPath
|
||||
}
|
||||
|
||||
private normalizeDatBase(name: string): string {
|
||||
let base = name.toLowerCase()
|
||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||
@@ -1154,122 +1257,6 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return this.stripDatVariantSuffix(baseLower) !== baseLower
|
||||
}
|
||||
|
||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower))
|
||||
}
|
||||
|
||||
|
||||
|
||||
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
|
||||
// 遍历所有可能的缓存根路径
|
||||
for (const root of allRoots) {
|
||||
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
|
||||
if (sessionId) {
|
||||
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
||||
if (existsSync(sessionDir)) {
|
||||
try {
|
||||
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||
.map(d => d.name)
|
||||
.sort()
|
||||
.reverse() // 最新的日期优先
|
||||
|
||||
for (const dateDir of dateDirs) {
|
||||
const imageDir = join(sessionDir, dateDir)
|
||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId)
|
||||
try {
|
||||
const sessionDirs = readdirSync(root, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
|
||||
for (const session of sessionDirs) {
|
||||
const sessionDir = join(root, session)
|
||||
// 检查是否是日期目录结构
|
||||
try {
|
||||
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||
.map(d => d.name)
|
||||
|
||||
for (const dateDir of subDirs) {
|
||||
const imageDir = join(sessionDir, dateDir)
|
||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg
|
||||
const oldImageDir = join(root, normalizedKey)
|
||||
if (existsSync(oldImageDir)) {
|
||||
const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
// 策略4: 最旧的平铺结构 Images/{file}.jpg
|
||||
for (const ext of extensions) {
|
||||
const candidate = join(root, `${cacheKey}${ext}`)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
const candidate = join(root, `${cacheKey}_t${ext}`)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private findCachedOutputInDir(
|
||||
dirPath: string,
|
||||
normalizedKey: string,
|
||||
extensions: string[],
|
||||
preferHd: boolean
|
||||
): string | null {
|
||||
// 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的)
|
||||
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
|
||||
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
|
||||
try {
|
||||
if (existsSync(hevcThumb)) {
|
||||
require('fs').unlinkSync(hevcThumb)
|
||||
}
|
||||
if (existsSync(hevcHd)) {
|
||||
require('fs').unlinkSync(hevcHd)
|
||||
}
|
||||
} catch { }
|
||||
|
||||
for (const ext of extensions) {
|
||||
if (preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
if (existsSync(hdPath)) return hdPath
|
||||
}
|
||||
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
|
||||
if (existsSync(thumbPath)) return thumbPath
|
||||
|
||||
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
|
||||
if (!preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
if (existsSync(hdPath)) return hdPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string {
|
||||
const name = basename(datPath)
|
||||
const lower = name.toLowerCase()
|
||||
@@ -1292,6 +1279,52 @@ export class ImageDecryptService {
|
||||
return join(outputDir, `${normalizedBase}${suffix}${ext}`)
|
||||
}
|
||||
|
||||
private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] {
|
||||
const name = basename(datPath)
|
||||
const lower = name.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
|
||||
const normalizedBase = this.normalizeDatBase(base)
|
||||
const suffixes = preferHd ? ['_hd', '_thumb'] : ['_thumb', '_hd']
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
|
||||
const root = this.getCacheRoot()
|
||||
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
|
||||
const timeDir = this.resolveTimeDir(datPath)
|
||||
const currentDir = join(root, contactDir, timeDir)
|
||||
const legacyDir = join(root, normalizedBase)
|
||||
const candidates: string[] = []
|
||||
|
||||
for (const suffix of suffixes) {
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(currentDir, `${normalizedBase}${suffix}${ext}`))
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧目录结构
|
||||
for (const suffix of suffixes) {
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(legacyDir, `${normalizedBase}${suffix}${ext}`))
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容最旧平铺结构
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(root, `${normalizedBase}${ext}`))
|
||||
candidates.push(join(root, `${normalizedBase}_t${ext}`))
|
||||
candidates.push(join(root, `${normalizedBase}_hd${ext}`))
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
|
||||
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private cacheResolvedPaths(cacheKey: string, imageMd5: string | undefined, imageDatName: string | undefined, outputPath: string): void {
|
||||
this.resolvedCache.set(cacheKey, outputPath)
|
||||
if (imageMd5 && imageMd5 !== cacheKey) {
|
||||
@@ -1334,34 +1367,26 @@ export class ImageDecryptService {
|
||||
if (imageDatName) this.updateFlags.delete(imageDatName)
|
||||
}
|
||||
|
||||
private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null {
|
||||
const keys = [
|
||||
imageDatName ? `${accountDir}|${imageDatName}` : null,
|
||||
imageDatName ? `${accountDir}|${this.normalizeDatBase(imageDatName)}` : null,
|
||||
imageMd5 ? `${accountDir}|${imageMd5}` : null
|
||||
].filter(Boolean) as string[]
|
||||
for (const key of keys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached)) return dirname(cached)
|
||||
private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
|
||||
try {
|
||||
const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
|
||||
if (typeof getter !== 'function') return []
|
||||
const windows = getter()
|
||||
if (!Array.isArray(windows)) return []
|
||||
return windows.filter((win) => (
|
||||
win &&
|
||||
typeof win.isDestroyed === 'function' &&
|
||||
win.webContents &&
|
||||
typeof win.webContents.send === 'function'
|
||||
))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
|
||||
return this.findPreferredDatVariantInDir(dirPath, baseName, false)
|
||||
}
|
||||
|
||||
private isNonThumbnailVariantDat(datPath: string): boolean {
|
||||
const lower = basename(datPath).toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
if (this.isThumbnailDat(lower)) return false
|
||||
const baseLower = lower.slice(0, -4)
|
||||
return this.isLikelyImageDatBase(baseLower)
|
||||
}
|
||||
|
||||
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
|
||||
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName }
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
for (const win of this.getActiveWindowsSafely()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('image:updateAvailable', message)
|
||||
}
|
||||
@@ -1370,7 +1395,7 @@ export class ImageDecryptService {
|
||||
|
||||
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void {
|
||||
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath }
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
for (const win of this.getActiveWindowsSafely()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('image:cacheResolved', message)
|
||||
}
|
||||
@@ -1395,261 +1420,45 @@ export class ImageDecryptService {
|
||||
status,
|
||||
message: message || ''
|
||||
}
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
for (const win of this.getActiveWindowsSafely()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('image:decryptProgress', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCacheIndexed(): Promise<void> {
|
||||
if (this.cacheIndexed) return
|
||||
if (this.cacheIndexing) return this.cacheIndexing
|
||||
this.cacheIndexing = (async () => {
|
||||
// 扫描所有可能的缓存根目录
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||
|
||||
for (const root of allRoots) {
|
||||
try {
|
||||
this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构
|
||||
} catch (e) {
|
||||
this.logError('索引目录失败', e, { root })
|
||||
}
|
||||
}
|
||||
|
||||
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
||||
this.cacheIndexed = true
|
||||
this.cacheIndexing = null
|
||||
})()
|
||||
return this.cacheIndexing
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可能的缓存根路径(用于查找已缓存的图片)
|
||||
* 包含当前路径、配置路径、旧版本路径
|
||||
*/
|
||||
private getAllCacheRoots(): string[] {
|
||||
const roots: string[] = []
|
||||
const configured = this.configService.get('cachePath')
|
||||
const documentsPath = app.getPath('documents')
|
||||
|
||||
// 主要路径(当前使用的)
|
||||
const mainRoot = this.getCacheRoot()
|
||||
roots.push(mainRoot)
|
||||
|
||||
// 如果配置了自定义路径,也检查其下的 Images
|
||||
if (configured) {
|
||||
roots.push(join(configured, 'Images'))
|
||||
roots.push(join(configured, 'images'))
|
||||
}
|
||||
|
||||
// 默认路径
|
||||
roots.push(join(documentsPath, 'WeFlow', 'Images'))
|
||||
roots.push(join(documentsPath, 'WeFlow', 'images'))
|
||||
|
||||
// 兼容旧路径(如果有的话)
|
||||
roots.push(join(documentsPath, 'WeFlowData', 'Images'))
|
||||
|
||||
// 去重并过滤存在的路径
|
||||
const uniqueRoots = Array.from(new Set(roots))
|
||||
const existingRoots = uniqueRoots.filter(r => existsSync(r))
|
||||
|
||||
return existingRoots
|
||||
}
|
||||
|
||||
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(root)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(root, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
if (depth < maxDepth) {
|
||||
this.indexCacheDir(fullPath, maxDepth, depth + 1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!stat.isFile()) continue
|
||||
const lower = entry.toLowerCase()
|
||||
const ext = extensions.find((item) => lower.endsWith(item))
|
||||
if (!ext) continue
|
||||
const base = entry.slice(0, -ext.length)
|
||||
this.addCacheIndex(base, fullPath)
|
||||
const normalized = this.normalizeDatBase(base)
|
||||
if (normalized && normalized !== base.toLowerCase()) {
|
||||
this.addCacheIndex(normalized, fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addCacheIndex(key: string, path: string): void {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
const existing = this.resolvedCache.get(normalizedKey)
|
||||
if (existing) {
|
||||
const existingIsThumb = this.isThumbnailPath(existing)
|
||||
const candidateIsThumb = this.isThumbnailPath(path)
|
||||
if (!existingIsThumb && candidateIsThumb) return
|
||||
}
|
||||
this.resolvedCache.set(normalizedKey, path)
|
||||
}
|
||||
|
||||
private getCacheRoot(): string {
|
||||
const configured = this.configService.get('cachePath')
|
||||
const root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(app.getPath('documents'), 'WeFlow', 'Images')
|
||||
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
|
||||
if (!existsSync(root)) {
|
||||
mkdirSync(root, { recursive: true })
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private resolveAesKey(aesKeyRaw: string): Buffer | null {
|
||||
const trimmed = aesKeyRaw?.trim() ?? ''
|
||||
if (!trimmed) return null
|
||||
return this.asciiKey16(trimmed)
|
||||
}
|
||||
|
||||
private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise<Buffer> {
|
||||
const version = this.getDatVersion(datPath)
|
||||
|
||||
if (version === 0) {
|
||||
return this.decryptDatV3(datPath, xorKey)
|
||||
}
|
||||
if (version === 1) {
|
||||
const key = this.asciiKey16(this.defaultV1AesKey)
|
||||
return this.decryptDatV4(datPath, xorKey, key)
|
||||
}
|
||||
// version === 2
|
||||
if (!aesKey || aesKey.length !== 16) {
|
||||
throw new Error('请到设置配置图片解密密钥')
|
||||
}
|
||||
return this.decryptDatV4(datPath, xorKey, aesKey)
|
||||
}
|
||||
|
||||
private getDatVersion(inputPath: string): number {
|
||||
if (!existsSync(inputPath)) {
|
||||
throw new Error('文件不存在')
|
||||
}
|
||||
const bytes = readFileSync(inputPath)
|
||||
if (bytes.length < 6) {
|
||||
return 0
|
||||
}
|
||||
const signature = bytes.subarray(0, 6)
|
||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
|
||||
return 1
|
||||
}
|
||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private decryptDatV3(inputPath: string, xorKey: number): Buffer {
|
||||
const data = readFileSync(inputPath)
|
||||
const out = Buffer.alloc(data.length)
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
out[i] = data[i] ^ xorKey
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer {
|
||||
const bytes = readFileSync(inputPath)
|
||||
if (bytes.length < 0x0f) {
|
||||
throw new Error('文件太小,无法解析')
|
||||
}
|
||||
|
||||
const header = bytes.subarray(0, 0x0f)
|
||||
const data = bytes.subarray(0x0f)
|
||||
const aesSize = this.bytesToInt32(header.subarray(6, 10))
|
||||
const xorSize = this.bytesToInt32(header.subarray(10, 14))
|
||||
|
||||
// AES 数据需要对齐到 16 字节(PKCS7 填充)
|
||||
// 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充
|
||||
const remainder = ((aesSize % 16) + 16) % 16
|
||||
const alignedAesSize = aesSize + (16 - remainder)
|
||||
|
||||
if (alignedAesSize > data.length) {
|
||||
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
|
||||
}
|
||||
|
||||
const aesData = data.subarray(0, alignedAesSize)
|
||||
let unpadded: Buffer = Buffer.alloc(0)
|
||||
if (aesData.length > 0) {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
|
||||
decipher.setAutoPadding(false)
|
||||
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
|
||||
|
||||
// 使用 PKCS7 填充移除
|
||||
unpadded = this.strictRemovePadding(decrypted)
|
||||
}
|
||||
|
||||
const remaining = data.subarray(alignedAesSize)
|
||||
if (xorSize < 0 || xorSize > remaining.length) {
|
||||
throw new Error('文件格式异常:XOR 数据长度不合法')
|
||||
}
|
||||
|
||||
let rawData = Buffer.alloc(0)
|
||||
let xoredData = Buffer.alloc(0)
|
||||
if (xorSize > 0) {
|
||||
const rawLength = remaining.length - xorSize
|
||||
if (rawLength < 0) {
|
||||
throw new Error('文件格式异常:原始数据长度小于XOR长度')
|
||||
}
|
||||
rawData = remaining.subarray(0, rawLength)
|
||||
const xorData = remaining.subarray(rawLength)
|
||||
xoredData = Buffer.alloc(xorData.length)
|
||||
for (let i = 0; i < xorData.length; i += 1) {
|
||||
xoredData[i] = xorData[i] ^ xorKey
|
||||
}
|
||||
} else {
|
||||
rawData = remaining
|
||||
xoredData = Buffer.alloc(0)
|
||||
}
|
||||
|
||||
return Buffer.concat([unpadded, rawData, xoredData])
|
||||
}
|
||||
|
||||
private bytesToInt32(bytes: Buffer): number {
|
||||
if (bytes.length !== 4) {
|
||||
throw new Error('需要 4 个字节')
|
||||
}
|
||||
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
||||
}
|
||||
|
||||
asciiKey16(keyString: string): Buffer {
|
||||
if (keyString.length < 16) {
|
||||
throw new Error('AES密钥至少需要 16 个字符')
|
||||
}
|
||||
return Buffer.from(keyString, 'ascii').subarray(0, 16)
|
||||
}
|
||||
|
||||
private strictRemovePadding(data: Buffer): Buffer {
|
||||
if (!data.length) {
|
||||
throw new Error('解密结果为空,填充非法')
|
||||
}
|
||||
const paddingLength = data[data.length - 1]
|
||||
if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) {
|
||||
throw new Error('PKCS7 填充长度非法')
|
||||
}
|
||||
for (let i = data.length - paddingLength; i < data.length; i += 1) {
|
||||
if (data[i] !== paddingLength) {
|
||||
throw new Error('PKCS7 填充内容非法')
|
||||
private tryDecryptDatWithNative(
|
||||
datPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
const result = decryptDatViaNative(datPath, xorKey, aesKey)
|
||||
if (!this.nativeLogged) {
|
||||
this.nativeLogged = true
|
||||
if (result) {
|
||||
this.logInfo('Rust 原生解密已启用', {
|
||||
addonPath: nativeAddonLocation(),
|
||||
source: 'native'
|
||||
})
|
||||
} else {
|
||||
this.logInfo('Rust 原生解密不可用', {
|
||||
addonPath: nativeAddonLocation(),
|
||||
source: 'native_unavailable'
|
||||
})
|
||||
}
|
||||
}
|
||||
return data.subarray(0, data.length - paddingLength)
|
||||
return result
|
||||
}
|
||||
|
||||
private detectImageExtension(buffer: Buffer): string | null {
|
||||
@@ -1723,91 +1532,6 @@ export class ImageDecryptService {
|
||||
return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp'
|
||||
}
|
||||
|
||||
private compareBytes(a: Buffer, b: Buffer): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 保留原有的批量检测 XOR 密钥方法(用于兼容)
|
||||
async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise<number | null> {
|
||||
const keyCount: Map<number, number> = new Map()
|
||||
let filesChecked = 0
|
||||
|
||||
const V1_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
|
||||
const V2_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
const IMAGE_SIGNATURES: { [key: string]: Buffer } = {
|
||||
jpg: Buffer.from([0xFF, 0xD8, 0xFF]),
|
||||
png: Buffer.from([0x89, 0x50, 0x4E, 0x47]),
|
||||
gif: Buffer.from([0x47, 0x49, 0x46, 0x38]),
|
||||
bmp: Buffer.from([0x42, 0x4D]),
|
||||
webp: Buffer.from([0x52, 0x49, 0x46, 0x46])
|
||||
}
|
||||
|
||||
const detectXorKeyFromV3 = (header: Buffer): number | null => {
|
||||
for (const [, signature] of Object.entries(IMAGE_SIGNATURES)) {
|
||||
const xorKey = header[0] ^ signature[0]
|
||||
let valid = true
|
||||
for (let i = 0; i < signature.length && i < header.length; i++) {
|
||||
if ((header[i] ^ xorKey) !== signature[i]) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (valid) return xorKey
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const scanDir = (dir: string) => {
|
||||
if (filesChecked >= maxFiles) return
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (filesChecked >= maxFiles) return
|
||||
const fullPath = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath)
|
||||
} else if (entry.name.endsWith('.dat')) {
|
||||
try {
|
||||
const header = Buffer.alloc(16)
|
||||
const fd = require('fs').openSync(fullPath, 'r')
|
||||
require('fs').readSync(fd, header, 0, 16, 0)
|
||||
require('fs').closeSync(fd)
|
||||
|
||||
if (header.subarray(0, 6).equals(V1_SIGNATURE) || header.subarray(0, 6).equals(V2_SIGNATURE)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = detectXorKeyFromV3(header)
|
||||
if (key !== null) {
|
||||
keyCount.set(key, (keyCount.get(key) || 0) + 1)
|
||||
filesChecked++
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
scanDir(dirPath)
|
||||
|
||||
if (keyCount.size === 0) return null
|
||||
|
||||
let maxCount = 0
|
||||
let mostCommonKey: number | null = null
|
||||
keyCount.forEach((count, key) => {
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
mostCommonKey = key
|
||||
}
|
||||
})
|
||||
|
||||
return mostCommonKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包 wxgf 格式
|
||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||
@@ -1857,45 +1581,39 @@ export class ImageDecryptService {
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const nalUnits: Buffer[] = []
|
||||
const starts: number[] = []
|
||||
let i = 4
|
||||
|
||||
while (i < buffer.length - 4) {
|
||||
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
|
||||
let nalStart = i
|
||||
let nalEnd = buffer.length
|
||||
while (i < buffer.length - 3) {
|
||||
const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01
|
||||
const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x01
|
||||
|
||||
for (let j = i + 4; j < buffer.length - 3; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
|
||||
if (buffer[j + 2] === 0x01 ||
|
||||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
|
||||
nalEnd = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nalUnit = buffer.subarray(nalStart, nalEnd)
|
||||
if (nalUnit.length > 3) {
|
||||
nalUnits.push(nalUnit)
|
||||
}
|
||||
i = nalEnd
|
||||
} else {
|
||||
i++
|
||||
if (hasPrefix4 || hasPrefix3) {
|
||||
starts.push(i)
|
||||
i += hasPrefix4 ? 4 : 3
|
||||
continue
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
if (nalUnits.length === 0) {
|
||||
for (let j = 4; j < buffer.length - 4; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
|
||||
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
|
||||
return buffer.subarray(j)
|
||||
}
|
||||
}
|
||||
return null
|
||||
if (starts.length === 0) return null
|
||||
|
||||
const nalUnits: Buffer[] = []
|
||||
for (let index = 0; index < starts.length; index += 1) {
|
||||
const start = starts[index]
|
||||
const end = index + 1 < starts.length ? starts[index + 1] : buffer.length
|
||||
const hasPrefix4 = buffer[start] === 0x00 && buffer[start + 1] === 0x00 &&
|
||||
buffer[start + 2] === 0x00 && buffer[start + 3] === 0x01
|
||||
const prefixLength = hasPrefix4 ? 4 : 3
|
||||
const payloadStart = start + prefixLength
|
||||
if (payloadStart >= end) continue
|
||||
nalUnits.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
|
||||
nalUnits.push(buffer.subarray(payloadStart, end))
|
||||
}
|
||||
|
||||
if (nalUnits.length === 0) return null
|
||||
return Buffer.concat(nalUnits)
|
||||
}
|
||||
|
||||
@@ -1921,11 +1639,11 @@ export class ImageDecryptService {
|
||||
const ffmpeg = this.getFfmpegPath()
|
||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||
|
||||
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
|
||||
const tmpDir = join(this.getTempPath(), 'weflow_hevc')
|
||||
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||
const ts = Date.now()
|
||||
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
|
||||
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
|
||||
const uniqueId = `${process.pid}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
|
||||
const tmpInput = join(tmpDir, `hevc_${uniqueId}.hevc`)
|
||||
const tmpOutput = join(tmpDir, `hevc_${uniqueId}.jpg`)
|
||||
|
||||
try {
|
||||
await writeFile(tmpInput, hevcData)
|
||||
@@ -1933,6 +1651,7 @@ export class ImageDecryptService {
|
||||
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
|
||||
const attempts: { label: string; inputArgs: string[] }[] = [
|
||||
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
|
||||
{ label: 'h265 raw', inputArgs: ['-f', 'h265', '-i', tmpInput] },
|
||||
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
|
||||
]
|
||||
|
||||
@@ -1961,6 +1680,7 @@ export class ImageDecryptService {
|
||||
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-y',
|
||||
...inputArgs,
|
||||
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
|
||||
]
|
||||
@@ -2015,11 +1735,6 @@ export class ImageDecryptService {
|
||||
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
|
||||
}
|
||||
|
||||
private hasXVariant(base: string): boolean {
|
||||
const lower = base.toLowerCase()
|
||||
return this.stripDatVariantSuffix(lower) !== lower
|
||||
}
|
||||
|
||||
private isHdPath(p: string): boolean {
|
||||
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
|
||||
}
|
||||
@@ -2044,42 +1759,40 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原有的解密到文件方法(用于兼容)
|
||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||
const version = this.getDatVersion(inputPath)
|
||||
let decrypted: Buffer
|
||||
|
||||
if (version === 0) {
|
||||
decrypted = this.decryptDatV3(inputPath, xorKey)
|
||||
} else if (version === 1) {
|
||||
const key = this.asciiKey16(this.defaultV1AesKey)
|
||||
decrypted = this.decryptDatV4(inputPath, xorKey, key)
|
||||
} else {
|
||||
if (!aesKey || aesKey.length !== 16) {
|
||||
throw new Error('V4版本需要 16 字节 AES 密钥')
|
||||
}
|
||||
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
|
||||
private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null {
|
||||
try {
|
||||
const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath
|
||||
if (typeof getter !== 'function') return null
|
||||
const value = getter(name)
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const outputDir = dirname(outputPath)
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
private getUserDataPath(): string {
|
||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||
if (workerUserDataPath) return workerUserDataPath
|
||||
return this.getElectronPath('userData') || process.cwd()
|
||||
}
|
||||
|
||||
await writeFile(outputPath, decrypted)
|
||||
private getDocumentsPath(): string {
|
||||
return this.getElectronPath('documents') || join(homedir(), 'Documents')
|
||||
}
|
||||
|
||||
private getTempPath(): string {
|
||||
return this.getElectronPath('temp') || tmpdir()
|
||||
}
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.resolvedCache.clear()
|
||||
this.pending.clear()
|
||||
this.updateFlags.clear()
|
||||
this.cacheIndexed = false
|
||||
this.cacheIndexing = null
|
||||
|
||||
const configured = this.configService.get('cachePath')
|
||||
const root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(app.getPath('documents'), 'WeFlow', 'Images')
|
||||
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
|
||||
|
||||
try {
|
||||
if (!existsSync(root)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ type PreloadImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
}
|
||||
|
||||
type PreloadOptions = {
|
||||
@@ -74,6 +75,9 @@ export class ImagePreloadService {
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
})
|
||||
@@ -82,7 +86,10 @@ export class ImagePreloadService {
|
||||
await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
type NativeDecryptResult = {
|
||||
data: Buffer
|
||||
ext: string
|
||||
isWxgf?: boolean
|
||||
is_wxgf?: boolean
|
||||
}
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
|
||||
function shouldEnableNative(): boolean {
|
||||
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
|
||||
}
|
||||
|
||||
function expandAsarCandidates(filePath: string): string[] {
|
||||
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
|
||||
return [filePath]
|
||||
}
|
||||
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
|
||||
}
|
||||
|
||||
function getPlatformDir(): string {
|
||||
if (process.platform === 'win32') return 'win32'
|
||||
if (process.platform === 'darwin') return 'macos'
|
||||
if (process.platform === 'linux') return 'linux'
|
||||
return process.platform
|
||||
}
|
||||
|
||||
function getArchDir(): string {
|
||||
if (process.arch === 'x64') return 'x64'
|
||||
if (process.arch === 'arm64') return 'arm64'
|
||||
return process.arch
|
||||
}
|
||||
|
||||
function getAddonCandidates(): string[] {
|
||||
const platformDir = getPlatformDir()
|
||||
const archDir = getArchDir()
|
||||
const cwd = process.cwd()
|
||||
const fileNames = [
|
||||
`weflow-image-native-${platformDir}-${archDir}.node`
|
||||
]
|
||||
const roots = [
|
||||
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
|
||||
...(process.resourcesPath
|
||||
? [
|
||||
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
|
||||
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
|
||||
]
|
||||
: [])
|
||||
]
|
||||
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
|
||||
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||
}
|
||||
|
||||
function loadAddon(): NativeAddon | null {
|
||||
if (!shouldEnableNative()) return null
|
||||
if (cachedAddon !== undefined) return cachedAddon
|
||||
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (!existsSync(candidate)) continue
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const addon = require(candidate) as NativeAddon
|
||||
if (addon && typeof addon.decryptDatNative === 'function') {
|
||||
cachedAddon = addon
|
||||
return addon
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
cachedAddon = null
|
||||
return null
|
||||
}
|
||||
|
||||
export function nativeAddonLocation(): string | null {
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function decryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon) return null
|
||||
|
||||
try {
|
||||
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
|
||||
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
|
||||
if (!result || !Buffer.isBuffer(result.data)) return null
|
||||
const rawExt = typeof result.ext === 'string' && result.ext.trim()
|
||||
? result.ext.trim().toLowerCase()
|
||||
: ''
|
||||
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||
return { data: result.data, ext, isWxgf }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Generated
+15
-14
@@ -9,7 +9,6 @@
|
||||
"version": "4.3.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -28,8 +27,9 @@
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.12.35",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -40,11 +40,11 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"sass": "^1.99.0",
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
}
|
||||
},
|
||||
@@ -3050,12 +3050,6 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/sudo-prompt": {
|
||||
"version": "9.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz",
|
||||
"integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
@@ -9462,6 +9456,13 @@
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/sudo-prompt": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
|
||||
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
@@ -10140,9 +10141,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-electron": {
|
||||
"version": "0.29.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz",
|
||||
"integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==",
|
||||
"version": "0.28.8",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz",
|
||||
"integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
|
||||
+4
-3
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jasonzhu1207/WeFlow"
|
||||
"url": "https://github.com/hicccc77/WeFlow"
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
@@ -77,7 +77,7 @@
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "Jasonzhu1207",
|
||||
"owner": "hicccc77",
|
||||
"repo": "WeFlow",
|
||||
"releaseType": "release"
|
||||
},
|
||||
@@ -186,7 +186,8 @@
|
||||
"node_modules/sherpa-onnx-node/**/*",
|
||||
"node_modules/sherpa-onnx-*/*",
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
"node_modules/ffmpeg-static/**/*",
|
||||
"resources/wedecrypt/**/*.node"
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+85
-29
@@ -154,6 +154,21 @@ function hasRenderableChatRecordName(value?: string): boolean {
|
||||
return value !== undefined && value !== null && String(value).length > 0
|
||||
}
|
||||
|
||||
function toRenderableImageSrc(path?: string): string | undefined {
|
||||
const raw = String(path || '').trim()
|
||||
if (!raw) return undefined
|
||||
if (/^(data:|blob:|https?:|file:)/i.test(raw)) return raw
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/')
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
return encodeURI(`file:///${normalized}`)
|
||||
}
|
||||
if (normalized.startsWith('/')) {
|
||||
return encodeURI(`file://${normalized}`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function getChatRecordPreviewText(item: ChatRecordItem): string {
|
||||
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
|
||||
if (item.datatype === 17) {
|
||||
@@ -4853,7 +4868,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
const candidates = [...head, ...tail]
|
||||
const queued = preloadImageKeysRef.current
|
||||
const seen = new Set<string>()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
for (const msg of candidates) {
|
||||
if (payloads.length >= maxPreload) break
|
||||
if (msg.localType !== 3) continue
|
||||
@@ -4867,11 +4882,14 @@ function ChatPage(props: ChatPageProps) {
|
||||
payloads.push({
|
||||
sessionId: currentSessionId,
|
||||
imageMd5: msg.imageMd5 || undefined,
|
||||
imageDatName: msg.imageDatName
|
||||
imageDatName: msg.imageDatName,
|
||||
createTime: msg.createTime
|
||||
})
|
||||
}
|
||||
if (payloads.length > 0) {
|
||||
window.electronAPI.image.preload(payloads).catch(() => { })
|
||||
window.electronAPI.image.preload(payloads, {
|
||||
allowCacheIndex: false
|
||||
}).catch(() => { })
|
||||
}
|
||||
}, [currentSessionId, messages])
|
||||
|
||||
@@ -5840,7 +5858,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
sessionId: session.username,
|
||||
imageMd5: img.imageMd5,
|
||||
imageDatName: img.imageDatName,
|
||||
force: true
|
||||
createTime: img.createTime,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
if (r?.success) successCount++
|
||||
else failCount++
|
||||
@@ -7882,7 +7903,7 @@ function MessageBubble({
|
||||
)
|
||||
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
|
||||
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
||||
() => imageDataUrlCache.get(imageCacheKey)
|
||||
() => toRenderableImageSrc(imageDataUrlCache.get(imageCacheKey))
|
||||
)
|
||||
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
|
||||
const voiceCacheKey = `voice:${voiceIdentityKey}`
|
||||
@@ -7904,6 +7925,7 @@ function MessageBubble({
|
||||
const imageUpdateCheckedRef = useRef<string | null>(null)
|
||||
const imageClickTimerRef = useRef<number | null>(null)
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const imageElementRef = useRef<HTMLImageElement | null>(null)
|
||||
const emojiContainerRef = useRef<HTMLDivElement>(null)
|
||||
const imageResizeBaselineRef = useRef<number | null>(null)
|
||||
const emojiResizeBaselineRef = useRef<number | null>(null)
|
||||
@@ -8260,19 +8282,27 @@ function MessageBubble({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName,
|
||||
force: forceUpdate
|
||||
createTime: message.createTime,
|
||||
force: forceUpdate,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
}) as SharedImageDecryptResult
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (imageLocalPath !== result.localPath) {
|
||||
const renderPath = toRenderableImageSrc(result.localPath)
|
||||
if (!renderPath) {
|
||||
if (!silent) setImageError(true)
|
||||
return { success: false }
|
||||
}
|
||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||
if (imageLocalPath !== renderPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageLocalPath(renderPath)
|
||||
setImageHasUpdate(false)
|
||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||
return result
|
||||
return { ...result, localPath: renderPath }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8297,7 +8327,7 @@ function MessageBubble({
|
||||
imageDecryptPendingRef.current = false
|
||||
}
|
||||
return { success: false }
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
const triggerForceHd = useCallback(() => {
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
@@ -8352,24 +8382,29 @@ function MessageBubble({
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
imageDatName: message.imageDatName,
|
||||
createTime: message.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
finalImagePath = resolved.localPath
|
||||
const renderPath = toRenderableImageSrc(resolved.localPath)
|
||||
if (!renderPath) return
|
||||
finalImagePath = renderPath
|
||||
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
|
||||
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
|
||||
if (imageLocalPath !== resolved.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||
if (imageLocalPath !== renderPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(resolved.localPath)
|
||||
setImageLocalPath(renderPath)
|
||||
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
|
||||
setImageHasUpdate(Boolean(resolved.hasUpdate))
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
|
||||
void window.electronAPI.window.openImageViewerWindow(toRenderableImageSrc(finalImagePath) || finalImagePath, finalLiveVideoPath)
|
||||
}, [
|
||||
imageLiveVideoPath,
|
||||
imageLocalPath,
|
||||
@@ -8378,6 +8413,7 @@ function MessageBubble({
|
||||
lockImageStageHeight,
|
||||
message.imageDatName,
|
||||
message.imageMd5,
|
||||
message.createTime,
|
||||
requestImageDecrypt,
|
||||
session.username
|
||||
])
|
||||
@@ -8391,8 +8427,19 @@ function MessageBubble({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
}, [imageLocalPath])
|
||||
if (!isImage) return
|
||||
if (!imageLocalPath) {
|
||||
setImageLoaded(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 某些 file:// 缓存图在 src 切换时可能不会稳定触发 onLoad,
|
||||
// 这里用 complete/naturalWidth 做一次兜底,避免图片进入 pending 隐身态。
|
||||
const img = imageElementRef.current
|
||||
if (img && img.complete && img.naturalWidth > 0) {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
}, [isImage, imageLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageLoading) return
|
||||
@@ -8401,7 +8448,7 @@ function MessageBubble({
|
||||
}, [imageError, imageLoading, imageLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || imageLoading) return
|
||||
if (!isImage || imageLoading || !imageInView) return
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
if (imageUpdateCheckedRef.current === imageCacheKey) return
|
||||
imageUpdateCheckedRef.current = imageCacheKey
|
||||
@@ -8409,15 +8456,21 @@ function MessageBubble({
|
||||
window.electronAPI.image.resolveCache({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
imageDatName: message.imageDatName,
|
||||
createTime: message.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: false
|
||||
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (!imageLocalPath || imageLocalPath !== result.localPath) {
|
||||
const renderPath = toRenderableImageSrc(result.localPath)
|
||||
if (!renderPath) return
|
||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||
if (!imageLocalPath || imageLocalPath !== renderPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageLocalPath(renderPath)
|
||||
setImageError(false)
|
||||
}
|
||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||
@@ -8427,7 +8480,7 @@ function MessageBubble({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
|
||||
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, message.createTime, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
@@ -8455,15 +8508,17 @@ function MessageBubble({
|
||||
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
|
||||
(payload.imageDatName && payload.imageDatName === message.imageDatName)
|
||||
if (matchesCacheKey) {
|
||||
const renderPath = toRenderableImageSrc(payload.localPath)
|
||||
if (!renderPath) return
|
||||
const cachedPath = imageDataUrlCache.get(imageCacheKey)
|
||||
if (cachedPath !== payload.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, payload.localPath)
|
||||
if (cachedPath !== renderPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||
}
|
||||
if (imageLocalPath !== payload.localPath) {
|
||||
if (imageLocalPath !== renderPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath))
|
||||
setImageLocalPath((prev) => (prev === renderPath ? prev : renderPath))
|
||||
setImageError(false)
|
||||
}
|
||||
})
|
||||
@@ -9093,6 +9148,7 @@ function MessageBubble({
|
||||
<>
|
||||
<div className="image-message-wrapper">
|
||||
<img
|
||||
ref={imageElementRef}
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||
|
||||
+37
-48
@@ -105,7 +105,6 @@ interface ExportOptions {
|
||||
txtColumns: string[]
|
||||
displayNamePreference: DisplayNamePreference
|
||||
exportConcurrency: number
|
||||
imageDeepSearchOnMiss: boolean
|
||||
}
|
||||
|
||||
interface SessionRow extends AppChatSession {
|
||||
@@ -336,6 +335,15 @@ const isTextBatchTask = (task: ExportTask): boolean => (
|
||||
task.payload.scope === 'content' && task.payload.contentType === 'text'
|
||||
)
|
||||
|
||||
const isImageExportTask = (task: ExportTask): boolean => {
|
||||
if (task.payload.scope === 'sns') {
|
||||
return Boolean(task.payload.snsOptions?.exportImages)
|
||||
}
|
||||
if (task.payload.scope !== 'content') return false
|
||||
if (task.payload.contentType === 'image') return true
|
||||
return Boolean(task.payload.options?.exportImages)
|
||||
}
|
||||
|
||||
const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
|
||||
if (phase === 'preparing') return 'collect'
|
||||
if (phase === 'writing') return 'write'
|
||||
@@ -1705,6 +1713,24 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
const currentSessionRatio = task.progress.phaseTotal > 0
|
||||
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
|
||||
: null
|
||||
const imageTask = isImageExportTask(task)
|
||||
const imageTimingElapsedMs = imageTask
|
||||
? Math.max(0, (
|
||||
typeof task.finishedAt === 'number'
|
||||
? task.finishedAt
|
||||
: nowTick
|
||||
) - (task.startedAt || task.createdAt))
|
||||
: 0
|
||||
const imageTimingAvgMs = imageTask && mediaDoneFiles > 0
|
||||
? Math.floor(imageTimingElapsedMs / Math.max(1, mediaDoneFiles))
|
||||
: 0
|
||||
const imageTimingLabel = imageTask
|
||||
? (
|
||||
mediaDoneFiles > 0
|
||||
? `图片耗时 ${formatDurationMs(imageTimingElapsedMs)} · 平均 ${imageTimingAvgMs}ms/张`
|
||||
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
|
||||
)
|
||||
: ''
|
||||
return (
|
||||
<div key={task.id} className={`task-card ${task.status}`}>
|
||||
<div className="task-main">
|
||||
@@ -1734,6 +1760,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{imageTimingLabel && task.status !== 'queued' && (
|
||||
<div className="task-perf-summary">
|
||||
<span>{imageTimingLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{canShowPerfDetail && stageTotals && (
|
||||
<div className="task-perf-summary">
|
||||
<span>累计耗时 {formatDurationMs(stageTotalMs)}</span>
|
||||
@@ -1903,7 +1934,6 @@ function ExportPage() {
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'json',
|
||||
@@ -1924,8 +1954,7 @@ function ExportPage() {
|
||||
excelCompactColumns: true,
|
||||
txtColumns: defaultTxtColumns,
|
||||
displayNamePreference: 'remark',
|
||||
exportConcurrency: 2,
|
||||
imageDeepSearchOnMiss: true
|
||||
exportConcurrency: 2
|
||||
})
|
||||
|
||||
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
||||
@@ -2622,7 +2651,7 @@ function ExportPage() {
|
||||
automationTasksReadyRef.current = false
|
||||
let isReady = true
|
||||
try {
|
||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
|
||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
|
||||
configService.getExportPath(),
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultAvatars(),
|
||||
@@ -2631,7 +2660,6 @@ function ExportPage() {
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
configService.getExportDefaultTxtColumns(),
|
||||
configService.getExportDefaultConcurrency(),
|
||||
configService.getExportDefaultImageDeepSearchOnMiss(),
|
||||
configService.getExportLastSessionRunMap(),
|
||||
configService.getExportLastContentRunMap(),
|
||||
configService.getExportSessionRecordMap(),
|
||||
@@ -2671,7 +2699,6 @@ function ExportPage() {
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
|
||||
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||
setAutomationTasks(automationTaskItem?.tasks || [])
|
||||
automationTasksReadyRef.current = true
|
||||
@@ -2709,8 +2736,7 @@ function ExportPage() {
|
||||
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
||||
txtColumns,
|
||||
exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
|
||||
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
|
||||
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
|
||||
}))
|
||||
} catch (error) {
|
||||
isReady = false
|
||||
@@ -4491,8 +4517,7 @@ function ExportPage() {
|
||||
maxFileSizeMb: prev.maxFileSizeMb,
|
||||
exportVoiceAsText: exportDefaultVoiceAsText,
|
||||
excelCompactColumns: exportDefaultExcelCompactColumns,
|
||||
exportConcurrency: exportDefaultConcurrency,
|
||||
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
|
||||
exportConcurrency: exportDefaultConcurrency
|
||||
}
|
||||
|
||||
if (payload.scope === 'sns') {
|
||||
@@ -4527,8 +4552,7 @@ function ExportPage() {
|
||||
exportDefaultAvatars,
|
||||
exportDefaultMedia,
|
||||
exportDefaultVoiceAsText,
|
||||
exportDefaultConcurrency,
|
||||
exportDefaultImageDeepSearchOnMiss
|
||||
exportDefaultConcurrency
|
||||
])
|
||||
|
||||
const closeExportDialog = useCallback(() => {
|
||||
@@ -4755,7 +4779,6 @@ function ExportPage() {
|
||||
txtColumns: options.txtColumns,
|
||||
displayNamePreference: options.displayNamePreference,
|
||||
exportConcurrency: options.exportConcurrency,
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
fileNamingMode: exportDefaultFileNamingMode,
|
||||
sessionLayout,
|
||||
sessionNameWithTypePrefix,
|
||||
@@ -5691,8 +5714,6 @@ function ExportPage() {
|
||||
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
||||
await configService.setExportDefaultTxtColumns(options.txtColumns)
|
||||
await configService.setExportDefaultConcurrency(options.exportConcurrency)
|
||||
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
|
||||
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
|
||||
}
|
||||
|
||||
const openSingleExport = useCallback((session: SessionRow) => {
|
||||
@@ -7393,14 +7414,6 @@ function ExportPage() {
|
||||
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
|
||||
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
||||
const shouldShowMediaSection = !isContentScopeDialog
|
||||
const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
|
||||
isSessionScopeDialog ||
|
||||
(isContentScopeDialog && exportDialog.contentType === 'image')
|
||||
)
|
||||
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
|
||||
(isSessionScopeDialog && options.exportImages) ||
|
||||
(isContentScopeDialog && exportDialog.contentType === 'image')
|
||||
)
|
||||
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
|
||||
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
|
||||
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
||||
@@ -9710,30 +9723,6 @@ function ExportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldRenderImageDeepSearchToggle && (
|
||||
<div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}>
|
||||
<div className="dialog-collapse-inner">
|
||||
<div className="dialog-section">
|
||||
<div className="dialog-switch-row">
|
||||
<div className="dialog-switch-copy">
|
||||
<h4>缺图时深度搜索</h4>
|
||||
<div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
|
||||
aria-pressed={options.imageDeepSearchOnMiss}
|
||||
aria-label="切换缺图时深度搜索"
|
||||
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
|
||||
>
|
||||
<span className="dialog-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSessionScopeDialog && (
|
||||
<div className="dialog-section">
|
||||
<div className="dialog-switch-row">
|
||||
|
||||
@@ -37,7 +37,6 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
||||
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
||||
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
|
||||
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
||||
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
|
||||
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
||||
@@ -548,18 +547,6 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
|
||||
}
|
||||
|
||||
// 获取缺图时是否深度搜索(默认导出行为)
|
||||
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置缺图时是否深度搜索(默认导出行为)
|
||||
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
|
||||
}
|
||||
|
||||
export type ExportWriteLayout = 'A' | 'B' | 'C'
|
||||
|
||||
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
|
||||
|
||||
Vendored
+15
-5
@@ -491,24 +491,35 @@ export interface ElectronAPI {
|
||||
}
|
||||
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||
decrypt: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||
error?: string
|
||||
}>
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => Promise<boolean>
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||
@@ -1117,7 +1128,6 @@ export interface ExportOptions {
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
imageDeepSearchOnMiss?: boolean
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
|
||||
Reference in new issue
Block a user