Merge branch 'hicccc77:main' into main

This commit is contained in:
Jason
2026-04-17 22:36:42 +08:00
committed by GitHub
43 changed files with 2967 additions and 1733 deletions

View File

@@ -372,6 +372,7 @@ if (process.platform === 'darwin') {
let mainWindowReady = false
let shouldShowMain = true
let isAppQuitting = false
let shutdownPromise: Promise<void> | null = null
let tray: Tray | null = null
let isClosePromptVisible = false
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
@@ -2663,15 +2664,30 @@ 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
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: 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
suppressEvents?: boolean
}) => {
return imageDecryptService.resolveCachedImage(payload)
})
@@ -2679,17 +2695,84 @@ 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
suppressEvents?: boolean
}>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => {
const list = Array.isArray(payloads) ? payloads : []
const rows = await Promise.all(list.map(async (payload) => {
return imageDecryptService.resolveCachedImage({
...payload,
disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false
})
}))
if (list.length === 0) return { success: true, rows: [] }
const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 10)
const maxConcurrent = Number.isFinite(maxConcurrentRaw)
? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 48))
: 10
const workerCount = Math.min(maxConcurrent, list.length)
const rows: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> = new Array(list.length)
let cursor = 0
const dedupe = new Map<string, Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>>()
const makeDedupeKey = (payload: typeof list[number]): string => {
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
const preferFilePath = payload.preferFilePath ?? options?.preferFilePath === true
const hardlinkOnly = payload.hardlinkOnly ?? options?.hardlinkOnly === true
const allowCacheIndex = options?.allowCacheIndex !== false
const disableUpdateCheck = options?.disableUpdateCheck === true
const suppressEvents = payload.suppressEvents ?? options?.suppressEvents === true
return [
sessionId,
imageMd5,
imageDatName,
String(createTime),
preferFilePath ? 'pf1' : 'pf0',
hardlinkOnly ? 'hl1' : 'hl0',
allowCacheIndex ? 'ci1' : 'ci0',
disableUpdateCheck ? 'du1' : 'du0',
suppressEvents ? 'se1' : 'se0'
].join('|')
}
const resolveOne = (payload: typeof list[number]) => imageDecryptService.resolveCachedImage({
...payload,
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false,
suppressEvents: payload.suppressEvents ?? options?.suppressEvents === true
})
const worker = async () => {
while (true) {
const index = cursor
cursor += 1
if (index >= list.length) return
const payload = list[index]
const key = makeDedupeKey(payload)
const existing = dedupe.get(key)
if (existing) {
rows[index] = await existing
continue
}
const task = resolveOne(payload).catch((error) => ({
success: false,
error: String(error)
}))
dedupe.set(key, task)
rows[index] = await task
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()))
return { success: true, rows }
}
)
@@ -2697,12 +2780,19 @@ 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)
return true
})
ipcMain.handle(
'image:preloadHardlinkMd5s',
async (_, md5List?: string[]) => {
await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : [])
return true
}
)
// Windows Hello
ipcMain.handle('auth:hello', async (event, message?: string) => {
@@ -3780,23 +3870,35 @@ app.whenReady().then(async () => {
})
})
app.on('before-quit', async () => {
isAppQuitting = true
// 销毁 tray 图标
if (tray) { try { tray.destroy() } catch {} tray = null }
// 通知窗使用 hide 而非 close退出时主动销毁避免残留窗口阻塞进程退出。
destroyNotificationWindow()
insightService.stop()
// 兜底5秒后强制退出防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout')
app.exit(0)
}, 5000)
forceExitTimer.unref()
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
try { await httpService.stop() } catch {}
// 终止 wcdb Worker 线程,避免线程阻止进程退出
try { await wcdbService.shutdown() } catch {}
const shutdownAppServices = async (): Promise<void> => {
if (shutdownPromise) return shutdownPromise
shutdownPromise = (async () => {
isAppQuitting = true
// 销毁 tray 图标
if (tray) { try { tray.destroy() } catch {} tray = null }
// 通知窗使用 hide 而非 close退出时主动销毁避免残留窗口阻塞进程退出。
destroyNotificationWindow()
messagePushService.stop()
insightService.stop()
// 兜底5秒后强制退出防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout')
app.exit(0)
}, 5000)
forceExitTimer.unref()
try { await cloudControlService.stop() } catch {}
// 停止 chatService内部会关闭 cursor 与 DB避免退出阶段仍触发监控回调
try { chatService.close() } catch {}
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
try { await httpService.stop() } catch {}
// 终止 wcdb Worker 线程,避免线程阻止进程退出
try { await wcdbService.shutdown() } catch {}
})()
return shutdownPromise
}
app.on('before-quit', () => {
void shutdownAppServices()
})
app.on('window-all-closed', () => {

View File

@@ -286,24 +286,41 @@ 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
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: 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; suppressEvents?: 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),
preloadHardlinkMd5s: (md5List: string[]) =>
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener)

View File

@@ -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),

View File

@@ -218,7 +218,7 @@ class CloudControlService {
this.pages.add(pageName)
}
stop() {
async stop(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
@@ -230,7 +230,13 @@ class CloudControlService {
this.circuitOpenedAt = 0
this.nextDelayOverrideMs = null
this.initialized = false
wcdbService.cloudStop()
if (wcdbService.isReady()) {
try {
await wcdbService.cloudStop()
} catch {
// 忽略停止失败,避免阻塞主进程退出
}
}
}
async getLogs() {

View File

@@ -2,6 +2,7 @@
import { app, safeStorage } from 'electron'
import crypto from 'crypto'
import Store from 'electron-store'
import { expandHomePath } from '../utils/pathUtils'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
@@ -42,7 +43,6 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[]
// 安全相关
@@ -168,7 +168,6 @@ export class ConfigService {
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
@@ -299,6 +298,10 @@ export class ConfigService {
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
}
if (key === 'dbPath' && typeof raw === 'string') {
return expandHomePath(raw) as ConfigSchema[K]
}
return raw
}
@@ -306,6 +309,10 @@ export class ConfigService {
let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
if (key === 'dbPath' && typeof value === 'string') {
toStore = expandHomePath(value) as ConfigSchema[K]
}
if (ENCRYPTED_BOOL_KEYS.has(key)) {
const boolValue = value === true || value === 'true'
// `false` 不需要写入 keychain避免无意义触发 macOS 钥匙串弹窗

View File

@@ -2,6 +2,7 @@ import { join, basename } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { homedir } from 'os'
import { createDecipheriv } from 'crypto'
import { expandHomePath } from '../utils/pathUtils'
export interface WxidInfo {
wxid: string
@@ -139,13 +140,14 @@ export class DbPathService {
* 查找账号目录(包含 db_storage 或图片目录)
*/
findAccountDirs(rootPath: string): string[] {
const resolvedRootPath = expandHomePath(rootPath)
const accounts: string[] = []
try {
const entries = readdirSync(rootPath)
const entries = readdirSync(resolvedRootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
const entryPath = join(resolvedRootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
@@ -216,13 +218,14 @@ export class DbPathService {
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
const wxids: WxidInfo[] = []
try {
if (existsSync(rootPath)) {
const entries = readdirSync(rootPath)
if (existsSync(resolvedRootPath)) {
const entries = readdirSync(resolvedRootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
const entryPath = join(resolvedRootPath, entry)
let stat: ReturnType<typeof statSync>
try { stat = statSync(entryPath) } catch { continue }
if (!stat.isDirectory()) continue
@@ -235,9 +238,9 @@ export class DbPathService {
if (wxids.length === 0) {
const rootName = basename(rootPath)
const rootName = basename(resolvedRootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
const rootStat = statSync(rootPath)
const rootStat = statSync(resolvedRootPath)
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
}
}
@@ -248,7 +251,7 @@ export class DbPathService {
return a.wxid.localeCompare(b.wxid)
});
const globalInfo = this.parseGlobalConfig(rootPath);
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
@@ -266,19 +269,20 @@ export class DbPathService {
* 扫描 wxid 列表
*/
scanWxids(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
const wxids: WxidInfo[] = []
try {
if (this.isAccountDir(rootPath)) {
const wxid = basename(rootPath)
const modifiedTime = this.getAccountModifiedTime(rootPath)
if (this.isAccountDir(resolvedRootPath)) {
const wxid = basename(resolvedRootPath)
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
return [{ wxid, modifiedTime }]
}
const accounts = this.findAccountDirs(rootPath)
const accounts = this.findAccountDirs(resolvedRootPath)
for (const account of accounts) {
const fullPath = join(rootPath, account)
const fullPath = join(resolvedRootPath, account)
const modifiedTime = this.getAccountModifiedTime(fullPath)
wxids.push({ wxid: account, modifiedTime })
}
@@ -289,7 +293,7 @@ export class DbPathService {
return a.wxid.localeCompare(b.wxid)
});
const globalInfo = this.parseGlobalConfig(rootPath);
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {

View File

@@ -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 }> = [
@@ -443,8 +442,8 @@ class ExportService {
let lastSessionId = ''
let lastCollected = 0
let lastExported = 0
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
const MIN_PROGRESS_EMIT_INTERVAL_MS = 400
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200
const commit = (progress: ExportProgress) => {
onProgress(progress)
@@ -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,26 +3679,31 @@ class ExportService {
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true,
hardlinkOnly: !imageDeepSearchOnMiss
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: !imageMd5,
suppressEvents: 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
if (result.failureKind === 'decrypt_failed') {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
} else {
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
}
// 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName,
preferFilePath: true
createTime: msg.createTime,
preferFilePath: true,
disableUpdateCheck: true,
allowCacheIndex: !imageMd5,
suppressEvents: true
})
if (thumbResult.success && thumbResult.localPath) {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
@@ -5302,7 +5300,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -5813,7 +5810,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -6685,7 +6681,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -7436,7 +7431,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -7816,7 +7810,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -8240,7 +8233,6 @@ class ExportService {
includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true,
exportVideos: options.exportVideos,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)

View File

@@ -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,54 @@ class HttpService {
sessionId: talker,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName,
force: true
createTime: msg.createTime,
force: true,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: 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,
disableUpdateCheck: true,
suppressEvents: 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}`)

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ type PreloadImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
}
type PreloadOptions = {
@@ -74,15 +75,24 @@ 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
allowCacheIndex: task.allowCacheIndex,
suppressEvents: true
})
if (cached.success) return
if (!task.allowDecrypt) return
await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
} catch {
// ignore preload failures

View File

@@ -478,8 +478,6 @@ export class KeyServiceMac {
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
'end try'
]
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
let stdout = ''
try {
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {

View File

@@ -53,6 +53,13 @@ class MessagePushService {
void this.refreshConfiguration('startup')
}
stop(): void {
this.started = false
this.processing = false
this.rerunRequested = false
this.resetRuntimeState()
}
handleDbMonitorChange(type: string, json: string): void {
if (!this.started) return
if (!this.isPushEnabled()) return

View File

@@ -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
}
}

View File

@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
import * as fzstd from 'fzstd'
import { expandHomePath } from '../utils/pathUtils'
//数据服务初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
@@ -481,7 +482,7 @@ export class WcdbCore {
private resolveDbStoragePath(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\\\/]+$/, '')
const normalized = expandHomePath(basePath).replace(/[\\\\/]+$/, '')
if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) {
return normalized
}
@@ -1600,6 +1601,9 @@ export class WcdbCore {
*/
close(): void {
if (this.handle !== null || this.initialized) {
// 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。
try { this.stopMonitor() } catch {}
try { this.cloudStop() } catch {}
try {
// 不调用 closeAccount直接 shutdown
this.wcdbShutdown()

View File

@@ -0,0 +1,20 @@
import { homedir } from 'os'
/**
* Expand "~" prefix to current user's home directory.
* Examples:
* - "~" => "/Users/alex"
* - "~/Library/..." => "/Users/alex/Library/..."
*/
export function expandHomePath(inputPath: string): string {
const raw = String(inputPath || '').trim()
if (!raw) return raw
if (raw === '~') return homedir()
if (/^~[\\/]/.test(raw)) {
return `${homedir()}${raw.slice(1)}`
}
return raw
}