diff --git a/.github/scripts/release-utils.sh b/.github/scripts/release-utils.sh index 1ebfc5e..390b56a 100644 --- a/.github/scripts/release-utils.sh +++ b/.github/scripts/release-utils.sh @@ -58,12 +58,26 @@ wait_for_release_id() { local i local release_id + local release_api_url for ((i = 1; i <= attempts; i++)); do release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)" if [[ "$release_id" =~ ^[0-9]+$ ]]; then echo "$release_id" return 0 fi + + release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)" + if [[ "$release_id" =~ ^[0-9]+$ ]]; then + echo "$release_id" + return 0 + fi + + release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)" + if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + if [ "$i" -lt "$attempts" ]; then echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2 sleep "$delay_seconds" @@ -71,6 +85,7 @@ wait_for_release_id() { done echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2 + gh release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true return 1 } @@ -87,9 +102,10 @@ settle_release_state() { local draft_state local prerelease_state for ((i = 1; i <= attempts; i++)); do + gh release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true - draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)" - prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)" + draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)" + prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)" if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then return 0 fi @@ -100,10 +116,19 @@ settle_release_state() { done echo "Failed to settle release state for tag '$tag'." >&2 + gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true return 1 } +print_release_state() { + local repo="$1" + local tag="$2" + + gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \ + || gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}' +} + wait_for_release_absent() { local repo="$1" local tag="$2" diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index 15f5450..67243ca 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -287,6 +287,12 @@ jobs: if: always() && needs.prepare.result == 'success' runs-on: ubuntu-latest steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + ref: ${{ env.TARGET_BRANCH }} + fetch-depth: 1 + - name: Update fixed dev release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -380,4 +386,4 @@ jobs: source .github/scripts/release-utils.sh RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)" settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2 - gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' + print_release_state "$REPO" "$TAG" diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 08b8556..13bc270 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -328,6 +328,12 @@ jobs: if: needs.prepare.outputs.should_build == 'true' && always() runs-on: ubuntu-latest steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + ref: ${{ env.TARGET_BRANCH }} + fetch-depth: 1 + - name: Update preview release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -423,4 +429,4 @@ jobs: source .github/scripts/release-utils.sh RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)" settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2 - gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' + print_release_state "$REPO" "$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33ae8f0..fe14ef8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,6 +252,11 @@ jobs: - release-windows-arm64 steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Generate release notes with platform download links env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -345,7 +350,6 @@ jobs: updpkgsums: true assets: | resources/installer/linux/weflow.desktop - resources/installer/linux/icon.png ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} commit_username: H3CoF6 diff --git a/.gitignore b/.gitignore index da6cc34..14d720f 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,5 @@ wechat-research-site .codex weflow-web-offical /Wedecrypt -/scripts/syncwcdb.py \ No newline at end of file +/scripts/syncwcdb.py +/scripts/syncWedecrypt.py \ No newline at end of file diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index c11c66a..736e2a4 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -194,7 +194,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1& "messages": [ { "localId": 123, - "serverId": "456", + "serverId": "6116895530414915131", "localType": 1, "createTime": 1738713600, "isSend": 0, diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index dfa4ba3..509c3dd 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -5,6 +5,7 @@ interface ExportWorkerConfig { sessionIds: string[] outputDir: string options: ExportOptions + taskId?: string dbPath?: string decryptKey?: string myWxid?: string @@ -14,6 +15,27 @@ interface ExportWorkerConfig { } const config = workerData as ExportWorkerConfig +const controlState = { + pauseRequested: false, + stopRequested: false +} + +parentPort?.on('message', (message: any) => { + if (!message || typeof message.type !== 'string') return + if (message.type === 'export:pause') { + controlState.pauseRequested = true + return + } + if (message.type === 'export:resume') { + controlState.pauseRequested = false + return + } + if (message.type === 'export:cancel') { + controlState.stopRequested = true + controlState.pauseRequested = false + } +}) + process.env.WEFLOW_WORKER = '1' if (config.resourcesPath) { process.env.WCDB_RESOURCES_PATH = config.resourcesPath @@ -47,7 +69,19 @@ async function run() { type: 'export:progress', data: progress }) - } + }, + config.taskId + ? { + shouldPause: () => controlState.pauseRequested, + shouldStop: () => controlState.stopRequested, + recordCreatedFile: (filePath: string) => { + parentPort?.postMessage({ type: 'export:createdFile', filePath }) + }, + recordCreatedDir: (dirPath: string) => { + parentPort?.postMessage({ type: 'export:createdDir', dirPath }) + } + } + : undefined ) parentPort?.postMessage({ diff --git a/electron/main.ts b/electron/main.ts index 57a9112..85f0863 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,7 @@ import { analyticsService } from './services/analyticsService' import { groupAnalyticsService } from './services/groupAnalyticsService' import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService' +import { exportTaskControlService } from './services/exportTaskControlService' import { KeyService } from './services/keyService' import { KeyServiceLinux } from './services/keyServiceLinux' import { KeyServiceMac } from './services/keyServiceMac' @@ -33,6 +34,7 @@ import { messagePushService } from './services/messagePushService' import { insightService } from './services/insightService' import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { bizService } from './services/bizService' +import { backupService } from './services/backupService' // 配置自动更新 autoUpdater.autoDownload = false @@ -63,6 +65,42 @@ const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => { return 'stable' })() let configService: ConfigService | null = null +const activeExportWorkers = new Map() +const activeExportTasks = new Set() + +const normalizeExportTaskId = (taskId: unknown): string => String(taskId || '').trim() + +const postExportWorkerControl = (taskId: string, action: 'pause' | 'resume' | 'cancel') => { + const worker = activeExportWorkers.get(taskId) + if (!worker) return + try { + worker.postMessage({ type: `export:${action}` }) + } catch (error) { + console.warn(`[export-task-control] failed to post ${action} to worker:`, error) + } +} + +const finalizeExportTaskControlResult = async (taskId: string, result: any) => { + if (!taskId) return result + if (result?.stopped) { + const cleanup = await exportTaskControlService.cleanupTask(taskId) + if (!cleanup.success) { + return { + ...result, + success: false, + error: `导出已停止,但清理已导出文件失败:${cleanup.error || '未知错误'}` + } + } + return { + ...result, + cleanup + } + } + if (!result?.paused) { + exportTaskControlService.releaseTask(taskId) + } + return result +} const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => { if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw @@ -747,6 +785,10 @@ const getWindowCloseBehavior = (): WindowCloseBehavior => { return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask' } +const isSilentStartupEnabled = (): boolean => { + return configService?.get('silentStartup') === true +} + const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => { if (isClosePromptVisible) return isClosePromptVisible = true @@ -2178,6 +2220,18 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('backup:create', async (_, payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => { + return backupService.createBackup(payload.outputPath, payload.options) + }) + + ipcMain.handle('backup:inspect', async (_, payload: { archivePath: string }) => { + return backupService.inspectBackup(payload.archivePath) + }) + + ipcMain.handle('backup:restore', async (_, payload: { archivePath: string }) => { + return backupService.restoreBackup(payload.archivePath) + }) + // 聊天相关 @@ -2224,6 +2278,10 @@ function registerIpcHandlers() { return chatService.getNewMessages(sessionId, minTime, limit) }) + ipcMain.handle('chat:getAntiRevokeSessions', async () => { + return chatService.getAntiRevokeSessions() + }) + ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => { return chatService.updateMessage(sessionId, localId, createTime, newContent) }) @@ -2615,16 +2673,25 @@ function registerIpcHandlers() { ipcMain.handle('sns:exportTimeline', async (event, options: any) => { const exportOptions = { ...(options || {}) } + const taskId = normalizeExportTaskId(exportOptions.taskId) delete exportOptions.taskId + const taskControl = taskId ? exportTaskControlService.createControl(taskId, String(exportOptions.outputDir || '')) : undefined + if (taskId) activeExportTasks.add(taskId) - return snsService.exportTimeline( - exportOptions, - (progress) => { - if (!event.sender.isDestroyed()) { - event.sender.send('sns:exportProgress', progress) - } - } - ) + try { + const result = await snsService.exportTimeline( + exportOptions, + (progress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('sns:exportProgress', progress) + } + }, + taskControl + ) + return finalizeExportTaskControlResult(taskId, result) + } finally { + if (taskId) activeExportTasks.delete(taskId) + } }) ipcMain.handle('sns:selectExportDir', async () => { @@ -2947,7 +3014,40 @@ function registerIpcHandlers() { return exportService.getExportStats(sessionIds, options) }) - ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { + ipcMain.handle('export:pauseTask', async (_, taskId: string) => { + const normalizedTaskId = normalizeExportTaskId(taskId) + if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' } + const success = exportTaskControlService.pauseTask(normalizedTaskId) + if (success) postExportWorkerControl(normalizedTaskId, 'pause') + return { success } + }) + + ipcMain.handle('export:resumeTask', async (_, taskId: string) => { + const normalizedTaskId = normalizeExportTaskId(taskId) + if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' } + const success = exportTaskControlService.resumeTask(normalizedTaskId) + if (success) postExportWorkerControl(normalizedTaskId, 'resume') + return { success } + }) + + ipcMain.handle('export:cancelTask', async (_, taskId: string) => { + const normalizedTaskId = normalizeExportTaskId(taskId) + if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' } + const success = exportTaskControlService.cancelTask(normalizedTaskId) + if (success) postExportWorkerControl(normalizedTaskId, 'cancel') + if (success && !activeExportTasks.has(normalizedTaskId)) { + const cleanup = await exportTaskControlService.cleanupTask(normalizedTaskId) + return cleanup.success + ? { success: true, cleanup } + : { success: false, error: cleanup.error || '清理已导出文件失败' } + } + return { success } + }) + + ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => { + const taskId = normalizeExportTaskId(controlOptions?.taskId) + const taskControl = taskId ? exportTaskControlService.createControl(taskId, outputDir) : undefined + if (taskId) activeExportTasks.add(taskId) const PROGRESS_FORWARD_INTERVAL_MS = 180 let pendingProgress: ExportProgress | null = null let progressTimer: NodeJS.Timeout | null = null @@ -2993,7 +3093,7 @@ function registerIpcHandlers() { const runMainFallback = async (reason: string) => { console.warn(`[fallback-export-main] ${reason}`) - return exportService.exportSessions(sessionIds, outputDir, options, onProgress) + return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl) } const cfg = configService || new ConfigService() @@ -3015,6 +3115,7 @@ function registerIpcHandlers() { sessionIds, outputDir, options, + taskId, dbPath, decryptKey, myWxid, @@ -3025,9 +3126,15 @@ function registerIpcHandlers() { }) let settled = false + if (taskId) { + activeExportWorkers.set(taskId, worker) + } const finalizeResolve = (value: any) => { if (settled) return settled = true + if (taskId && activeExportWorkers.get(taskId) === worker) { + activeExportWorkers.delete(taskId) + } worker.removeAllListeners() void worker.terminate() resolve(value) @@ -3035,6 +3142,9 @@ function registerIpcHandlers() { const finalizeReject = (error: Error) => { if (settled) return settled = true + if (taskId && activeExportWorkers.get(taskId) === worker) { + activeExportWorkers.delete(taskId) + } worker.removeAllListeners() void worker.terminate() reject(error) @@ -3045,6 +3155,14 @@ function registerIpcHandlers() { onProgress(msg.data as ExportProgress) return } + if (msg && msg.type === 'export:createdFile' && taskId) { + exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || '')) + return + } + if (msg && msg.type === 'export:createdDir' && taskId) { + exportTaskControlService.recordCreatedDir(taskId, String(msg.dirPath || '')) + return + } if (msg && msg.type === 'export:result') { finalizeResolve(msg.data) return @@ -3070,10 +3188,13 @@ function registerIpcHandlers() { } try { - return await runWorker() + const result = await runWorker() + return await finalizeExportTaskControlResult(taskId, result) } catch (error) { - return runMainFallback(error instanceof Error ? error.message : String(error)) + const result = await runMainFallback(error instanceof Error ? error.message : String(error)) + return await finalizeExportTaskControlResult(taskId, result) } finally { + if (taskId) activeExportTasks.delete(taskId) flushProgress() if (progressTimer) { clearTimeout(progressTimer) @@ -3727,21 +3848,31 @@ function checkForUpdatesOnStartup() { } app.whenReady().then(async () => { - // 立即创建 Splash 窗口,确保用户尽快看到反馈 - createSplashWindow() + // 先初始化配置,以便在启动早期判定是否需要静默启动 + configService = new ConfigService() + applyAutoUpdateChannel('startup') + syncLaunchAtStartupPreference() + const onboardingDone = configService.get('onboardingDone') === true + const startInBackground = onboardingDone && isSilentStartupEnabled() + shouldShowMain = onboardingDone - // 等待 Splash 页面加载完成后再推送进度 - if (splashWindow) { - await new Promise((resolve) => { - if (splashWindow!.webContents.isLoading()) { - splashWindow!.webContents.once('did-finish-load', () => resolve()) - } else { - resolve() - } - }) - splashWindow.webContents - .executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`) - .catch(() => {}) + if (!startInBackground) { + // 非静默模式下显示 Splash,提供启动反馈 + createSplashWindow() + + // 等待 Splash 页面加载完成后再推送进度 + if (splashWindow) { + await new Promise((resolve) => { + if (splashWindow!.webContents.isLoading()) { + splashWindow!.webContents.once('did-finish-load', () => resolve()) + } else { + resolve() + } + }) + splashWindow.webContents + .executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`) + .catch(() => {}) + } } const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -3770,13 +3901,7 @@ app.whenReady().then(async () => { }) } - // 初始化配置服务 updateSplashProgress(5, '正在加载配置...') - configService = new ConfigService() - applyAutoUpdateChannel('startup') - syncLaunchAtStartupPreference() - const onboardingDone = configService.get('onboardingDone') === true - shouldShowMain = onboardingDone // 将用户主题配置推送给 Splash 窗口 if (splashWindow && !splashWindow.isDestroyed()) { @@ -3943,6 +4068,8 @@ app.whenReady().then(async () => { if (!onboardingDone) { createOnboardingWindow() + } else if (startInBackground && tray) { + mainWindow?.hide() } else { mainWindow?.show() } @@ -3996,4 +4123,3 @@ app.on('window-all-closed', () => { } }) - diff --git a/electron/preload.ts b/electron/preload.ts index 28959b7..c7ba7c2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -154,6 +154,17 @@ contextBridge.exposeInMainWorld('electronAPI', { }, + backup: { + create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload), + inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload), + restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload), + onProgress: (callback: (progress: any) => void) => { + const listener = (_: unknown, progress: any) => callback(progress) + ipcRenderer.on('backup:progress', listener) + return () => ipcRenderer.removeListener('backup:progress', listener) + } + }, + // 密钥获取 key: { autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), @@ -174,6 +185,7 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), + getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'), getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), @@ -451,8 +463,14 @@ contextBridge.exposeInMainWorld('electronAPI', { export: { getExportStats: (sessionIds: string[], options: any) => ipcRenderer.invoke('export:getExportStats', sessionIds, options), - exportSessions: (sessionIds: string[], outputDir: string, options: any) => - ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), + exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) => + ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions), + pauseTask: (taskId: string) => + ipcRenderer.invoke('export:pauseTask', taskId), + resumeTask: (taskId: string) => + ipcRenderer.invoke('export:resumeTask', taskId), + cancelTask: (taskId: string) => + ipcRenderer.invoke('export:cancelTask', taskId), exportSession: (sessionId: string, outputPath: string, options: any) => ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), exportContacts: (outputDir: string, options: any) => diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts new file mode 100644 index 0000000..263afa0 --- /dev/null +++ b/electron/services/backupService.ts @@ -0,0 +1,1084 @@ +import { BrowserWindow, app } from 'electron' +import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs' +import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises' +import { basename, dirname, join, relative, resolve, sep } from 'path' +import { tmpdir } from 'os' +import * as tar from 'tar' +import { ConfigService } from './config' +import { wcdbService } from './wcdbService' +import { expandHomePath } from '../utils/pathUtils' + +type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink' +type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed' +type BackupResourceKind = 'image' | 'video' | 'file' +const TEMP_MARKER = '.weflow-backup-temp' +const TEMP_TTL_MS = 24 * 60 * 60 * 1000 + +export interface BackupOptions { + includeImages?: boolean + includeVideos?: boolean + includeFiles?: boolean +} + +interface BackupDbEntry { + id: string + kind: BackupDbKind + dbPath: string + relativePath: string + tables: BackupTableEntry[] +} + +interface BackupTableEntry { + name: string + snapshotPath: string + rows: number + columns: number + schemaSql?: string +} + +interface BackupResourceEntry { + kind: BackupResourceKind + id: string + md5?: string + sessionId?: string + createTime?: number + sourceFileName?: string + archivePath: string + targetRelativePath: string + ext?: string + size?: number +} + +interface BackupManifest { + version: 1 + type: 'weflow-db-snapshots' + createdAt: string + appVersion: string + source: { + wxid: string + dbRoot: string + } + databases: BackupDbEntry[] + options?: BackupOptions + resources?: { + images?: BackupResourceEntry[] + videos?: BackupResourceEntry[] + files?: BackupResourceEntry[] + } +} + +interface BackupProgress { + phase: BackupPhase + message: string + current?: number + total?: number + detail?: string +} + +function emitBackupProgress(progress: BackupProgress): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send('backup:progress', progress) + } + } +} + +function safeName(value: string): string { + return encodeURIComponent(value || 'unnamed').replace(/%/g, '_') +} + +function toArchivePath(path: string): string { + return path.split(sep).join('/') +} + +async function withTimeout(task: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | null = null + try { + return await Promise.race([ + task, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs) + }) + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +function delay(ms = 0): Promise { + return new Promise(resolveDelay => setTimeout(resolveDelay, ms)) +} + +function createThrottledProgressEmitter(minIntervalMs = 120): (progress: BackupProgress, force?: boolean) => void { + let lastEmitAt = 0 + return (progress: BackupProgress, force = false) => { + const now = Date.now() + if (!force && now - lastEmitAt < minIntervalMs) return + lastEmitAt = now + emitBackupProgress(progress) + } +} + +async function runWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise +): Promise { + let nextIndex = 0 + const workerCount = Math.max(1, Math.min(concurrency, items.length)) + await Promise.all(Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex + nextIndex += 1 + if (index >= items.length) return + await worker(items[index], index) + if (index % 50 === 0) await delay() + } + })) +} + +function hasResourceOptions(options: BackupOptions): boolean { + return options.includeImages === true || options.includeVideos === true || options.includeFiles === true +} + +function normalizeArchivePath(value: string): string { + return String(value || '').replace(/\\/g, '/') +} + +export class BackupService { + private configService = new ConfigService() + private cleanedTempDirs = false + + private cleanupStaleTempDirs(): void { + if (this.cleanedTempDirs) return + this.cleanedTempDirs = true + const root = tmpdir() + const now = Date.now() + try { + for (const entry of readdirSync(root)) { + if (!entry.startsWith('weflow-backup-')) continue + const dir = join(root, entry) + const marker = join(dir, TEMP_MARKER) + try { + const stat = statSync(dir) + if (!stat.isDirectory()) continue + if (!existsSync(marker)) continue + const age = now - stat.mtimeMs + if (age < TEMP_TTL_MS) continue + rmSync(dir, { recursive: true, force: true }) + } catch {} + } + } catch {} + } + + private async createTempDir(prefix: string): Promise { + this.cleanupStaleTempDirs() + const dir = await mkdtemp(join(tmpdir(), prefix)) + await writeFile(join(dir, TEMP_MARKER), String(Date.now()), 'utf8') + return dir + } + + private buildWxidCandidates(wxid: string): string[] { + const wxidCandidates = Array.from(new Set([ + String(wxid || '').trim(), + this.cleanAccountDirName(wxid) + ].filter(Boolean))) + return wxidCandidates + } + + private isCurrentAccountDir(accountDir: string, wxidCandidates: string[]): boolean { + const accountName = basename(accountDir).toLowerCase() + return wxidCandidates + .map(item => item.toLowerCase()) + .some(wxid => accountName === wxid || accountName.startsWith(`${wxid}_`)) + } + + private normalizeExistingPath(inputPath: string): string { + const expanded = expandHomePath(String(inputPath || '').trim()).replace(/[\\/]+$/, '') + if (!expanded) return expanded + try { + if (existsSync(expanded) && statSync(expanded).isFile()) { + return dirname(expanded) + } + } catch {} + return expanded + } + + private resolveAncestorDbStorage(normalized: string, wxidCandidates: string[]): string | null { + let current = normalized + for (let i = 0; i < 8; i += 1) { + if (!current) break + if (basename(current).toLowerCase() === 'db_storage') { + const accountDir = dirname(current) + if (this.isCurrentAccountDir(accountDir, wxidCandidates) && existsSync(current)) { + return current + } + } + const parent = dirname(current) + if (!parent || parent === current) break + current = parent + } + return null + } + + private resolveCurrentAccountDbStorageFromRoot(rootPath: string, wxidCandidates: string[]): string | null { + if (!rootPath || !existsSync(rootPath)) return null + + for (const candidateWxid of wxidCandidates) { + const viaWxid = join(rootPath, candidateWxid, 'db_storage') + if (existsSync(viaWxid)) return viaWxid + } + + try { + const entries = readdirSync(rootPath) + const loweredWxids = wxidCandidates.map(item => item.toLowerCase()) + for (const entry of entries) { + const entryPath = join(rootPath, entry) + try { + if (!statSync(entryPath).isDirectory()) continue + } catch { + continue + } + const lowerEntry = entry.toLowerCase() + if (!loweredWxids.some(id => lowerEntry === id || lowerEntry.startsWith(`${id}_`))) continue + const candidate = join(entryPath, 'db_storage') + if (existsSync(candidate)) return candidate + } + } catch {} + + return null + } + + private resolveDbStoragePath(dbPath: string, wxid: string): string | null { + const normalized = this.normalizeExistingPath(dbPath) + if (!normalized) return null + + const wxidCandidates = this.buildWxidCandidates(wxid) + const ancestor = this.resolveAncestorDbStorage(normalized, wxidCandidates) + if (ancestor) return ancestor + + const direct = join(normalized, 'db_storage') + if (existsSync(direct) && this.isCurrentAccountDir(normalized, wxidCandidates)) return direct + + const roots = Array.from(new Set([ + normalized, + join(normalized, 'WeChat Files'), + join(normalized, 'xwechat_files') + ])) + for (const root of roots) { + const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, wxidCandidates) + if (dbStorage) return dbStorage + } + + return null + } + + private resolveAccountDir(dbPath: string, wxid: string): string | null { + const dbStorage = this.resolveDbStoragePath(dbPath, wxid) + return dbStorage ? dirname(dbStorage) : null + } + + private cleanAccountDirName(wxid: string): string { + const trimmed = String(wxid || '').trim() + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise { + const dir = join(root, rel) + const files: string[] = [] + for (const entry of readdirSync(dir)) { + const entryRel = rel ? join(rel, entry) : entry + const entryPath = join(root, entryRel) + try { + const stat = statSync(entryPath) + if (stat.isDirectory()) { + files.push(...await this.listFilesForArchive(root, entryRel, state)) + } else if (stat.isFile()) { + files.push(toArchivePath(entryRel)) + } + state.visited += 1 + if (state.visited % 200 === 0) await delay() + } catch {} + } + return files + } + + private resolveExtractedPath(extractDir: string, archivePath: string): string | null { + const normalized = normalizeArchivePath(archivePath) + if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null + const root = resolve(extractDir) + const target = resolve(join(extractDir, normalized)) + if (target !== root && !target.startsWith(`${root}${sep}`)) return null + return target + } + + private resolveStagingPath(stagingDir: string, archivePath: string): string | null { + return this.resolveExtractedPath(stagingDir, archivePath) + } + + private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null { + const normalized = normalizeArchivePath(relativePath) + if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null + const root = resolve(accountDir) + const target = resolve(join(accountDir, normalized)) + if (target !== root && !target.startsWith(`${root}${sep}`)) return null + return target + } + + private isSafeAccountRelativePath(accountDir: string, filePath: string): string | null { + const rel = toArchivePath(relative(accountDir, filePath)) + if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null + return rel + } + + private async listFilesUnderDir(root: string, state = { visited: 0 }): Promise { + const files: string[] = [] + if (!existsSync(root)) return files + try { + for (const entry of readdirSync(root)) { + const fullPath = join(root, entry) + let stat + try { + stat = statSync(fullPath) + } catch { + continue + } + if (stat.isDirectory()) { + files.push(...await this.listFilesUnderDir(fullPath, state)) + } else if (stat.isFile()) { + files.push(fullPath) + } + state.visited += 1 + if (state.visited % 300 === 0) await delay() + } + } catch {} + return files + } + + private async stagePlainResource(sourcePath: string, outputPath: string): Promise { + mkdirSync(dirname(outputPath), { recursive: true }) + try { + await link(sourcePath, outputPath) + } catch { + await copyFile(sourcePath, outputPath) + } + } + + private async writeTarEntryToFile(entry: any, outputPath: string): Promise { + mkdirSync(dirname(outputPath), { recursive: true }) + await new Promise((resolvePromise, rejectPromise) => { + const out = createWriteStream(outputPath) + const fail = (error: unknown) => rejectPromise(error instanceof Error ? error : new Error(String(error))) + out.on('finish', resolvePromise) + out.on('error', fail) + entry.on('error', fail) + entry.pipe(out) + }) + } + + private async listChatImageDatFiles(accountDir: string): Promise { + const attachRoot = join(accountDir, 'msg', 'attach') + const result: string[] = [] + if (!existsSync(attachRoot)) return result + + const scanImgDir = async (imgDir: string): Promise => { + let entries: string[] = [] + try { + entries = readdirSync(imgDir) + } catch { + return + } + for (const entry of entries) { + const fullPath = join(imgDir, entry) + let stat + try { + stat = statSync(fullPath) + } catch { + continue + } + if (stat.isFile() && entry.toLowerCase().endsWith('.dat')) { + result.push(fullPath) + } else if (stat.isDirectory()) { + let nestedEntries: string[] = [] + try { + nestedEntries = readdirSync(fullPath) + } catch { + continue + } + for (const nestedEntry of nestedEntries) { + const nestedPath = join(fullPath, nestedEntry) + try { + if (statSync(nestedPath).isFile() && nestedEntry.toLowerCase().endsWith('.dat')) { + result.push(nestedPath) + } + } catch {} + } + } + if (result.length > 0 && result.length % 500 === 0) await delay() + } + } + + const walk = async (dir: string): Promise => { + let entries: Array<{ name: string; isDirectory: () => boolean }> = [] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + const child = join(dir, entry.name) + if (entry.name.toLowerCase() === 'img') { + await scanImgDir(child) + } else { + await walk(child) + } + if (result.length > 0 && result.length % 500 === 0) await delay() + } + } + + await walk(attachRoot) + return Array.from(new Set(result)) + } + + private async ensureConnected(wxidOverride?: string): Promise<{ success: boolean; wxid?: string; dbPath?: string; dbStorage?: string; error?: string }> { + const configuredWxid = String(this.configService.get('myWxid') || '').trim() + const wxid = String(wxidOverride || configuredWxid || '').trim() + const dbPath = String(this.configService.get('dbPath') || '').trim() + const decryptKey = String(this.configService.get('decryptKey') || '').trim() + if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' } + if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' } + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` } + const dbStorage = join(accountDir, 'db_storage') + if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' } + + const accountDirName = basename(accountDir) + const opened = await withTimeout( + wcdbService.open(dbPath, decryptKey, accountDirName), + 15000, + '连接目标账号数据库超时,请检查数据库路径、密钥是否正确' + ) + if (!opened) { + const detail = await wcdbService.getLastInitError().catch(() => null) + return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` } + } + + return { success: true, wxid: accountDirName, dbPath, dbStorage } + } + + private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string { + if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') return kind + return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}` + } + + private toDbRelativePath(dbStorage: string, dbPath: string): string { + const rel = toArchivePath(relative(dbStorage, dbPath)) + if (!rel || rel.startsWith('..') || rel.startsWith('/')) return basename(dbPath) + return rel + } + + private resolveTargetDbPath(dbStorage: string, relativePath: string): string | null { + const normalized = String(relativePath || '').replace(/\\/g, '/') + if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null + const root = resolve(dbStorage) + const target = resolve(join(dbStorage, normalized)) + if (target !== root && !target.startsWith(`${root}${sep}`)) return null + return target + } + + private defaultRelativeDbPath(kind: BackupDbKind): string | null { + if (kind === 'session') return 'session/session.db' + if (kind === 'contact') return 'contact/contact.db' + if (kind === 'emoticon') return 'emoticon/emoticon.db' + if (kind === 'sns') return 'sns/sns.db' + if (kind === 'hardlink') return 'hardlink/hardlink.db' + return null + } + + private resolveRestoreTargetDbPath(dbStorage: string, db: BackupDbEntry): string | null { + const normalized = String(db.relativePath || '').replace(/\\/g, '/') + const legacyFixedPath = this.defaultRelativeDbPath(db.kind) + if (legacyFixedPath && (!normalized.includes('/') || !normalized.toLowerCase().endsWith('.db'))) { + return this.resolveTargetDbPath(dbStorage, legacyFixedPath) + } + return this.resolveTargetDbPath(dbStorage, db.relativePath) + } + + private findFirstExisting(paths: string[]): string { + for (const path of paths) { + try { + if (existsSync(path) && statSync(path).isFile()) return path + } catch {} + } + return '' + } + + private resolveKnownDbPath(kind: BackupDbKind, dbStorage: string): string { + if (kind === 'session') { + return this.findFirstExisting([ + join(dbStorage, 'session', 'session.db'), + join(dbStorage, 'Session', 'session.db'), + join(dbStorage, 'session.db') + ]) + } + if (kind === 'contact') { + return this.findFirstExisting([ + join(dbStorage, 'Contact', 'contact.db'), + join(dbStorage, 'Contact', 'Contact.db'), + join(dbStorage, 'contact', 'contact.db'), + join(dbStorage, 'session', 'contact.db') + ]) + } + if (kind === 'emoticon') { + return this.findFirstExisting([ + join(dbStorage, 'emoticon', 'emoticon.db'), + join(dbStorage, 'emotion', 'emoticon.db') + ]) + } + if (kind === 'sns') { + return this.findFirstExisting([ + join(dbStorage, 'sns', 'sns.db'), + join(dirname(dbStorage), 'sns', 'sns.db') + ]) + } + if (kind === 'hardlink') { + return this.findFirstExisting([ + join(dbStorage, 'hardlink', 'hardlink.db'), + join(dbStorage, 'hardlink.db'), + join(dirname(dbStorage), 'hardlink.db') + ]) + } + return '' + } + + private async collectDatabases(dbStorage: string): Promise>> { + const result: Array> = [] + for (const kind of ['session', 'contact', 'emoticon', 'sns', 'hardlink'] as const) { + const dbPath = this.resolveKnownDbPath(kind, dbStorage) + result.push({ + id: kind, + kind, + dbPath, + relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind + }) + } + + const messageDbs = await wcdbService.listMessageDbs() + if (messageDbs.success && Array.isArray(messageDbs.data)) { + messageDbs.data.forEach((dbPath, index) => { + result.push({ + id: this.buildDbId('message', index, dbPath), + kind: 'message', + dbPath, + relativePath: this.toDbRelativePath(dbStorage, dbPath) + }) + }) + } + + const mediaDbs = await wcdbService.listMediaDbs() + if (mediaDbs.success && Array.isArray(mediaDbs.data)) { + mediaDbs.data.forEach((dbPath, index) => { + result.push({ + id: this.buildDbId('media', index, dbPath), + kind: 'media', + dbPath, + relativePath: this.toDbRelativePath(dbStorage, dbPath) + }) + }) + } + + return result + } + + private async collectImageResources( + connected: { wxid: string; dbStorage: string }, + stagingDir: string, + manifest: BackupManifest + ): Promise { + const accountDir = dirname(connected.dbStorage) + const imagesDir = join(stagingDir, 'resources', 'images') + const imagePaths = await this.listChatImageDatFiles(accountDir) + if (imagePaths.length === 0) return + + mkdirSync(imagesDir, { recursive: true }) + const resources: BackupResourceEntry[] = [] + const emitImageProgress = createThrottledProgressEmitter(160) + for (let index = 0; index < imagePaths.length; index += 1) { + const sourcePath = imagePaths[index] + const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath) + if (!relativeTarget) continue + emitImageProgress({ + phase: 'exporting', + message: '正在打包图片资源', + current: index + 1, + total: imagePaths.length, + detail: relativeTarget + }) + const archivePath = toArchivePath(join('resources', 'images', relativeTarget)) + const outputPath = join(stagingDir, archivePath) + await this.stagePlainResource(sourcePath, outputPath) + const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase() + const stat = statSync(sourcePath) + resources.push({ + kind: 'image', + id: relativeTarget, + md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined, + sourceFileName: basename(sourcePath), + archivePath, + targetRelativePath: relativeTarget, + size: stat.size + }) + if (index % 20 === 0) await delay() + } + + if (resources.length > 0) { + manifest.resources = { ...(manifest.resources || {}), images: resources } + } + } + + private async collectPlainResources( + connected: { dbStorage: string }, + stagingDir: string, + manifest: BackupManifest, + kind: 'video' | 'file' + ): Promise { + const accountDir = dirname(connected.dbStorage) + const roots = kind === 'video' + ? [ + join(accountDir, 'msg', 'video'), + join(accountDir, 'FileStorage', 'Video') + ] + : [ + join(accountDir, 'FileStorage', 'File'), + join(accountDir, 'msg', 'file') + ] + const listed = await Promise.all(roots.map(root => this.listFilesUnderDir(root))) + const uniqueFiles = Array.from(new Set(listed.flat())) + if (uniqueFiles.length === 0) return + + const resources: BackupResourceEntry[] = [] + const bucket = kind === 'video' ? 'videos' : 'files' + const emitResourceProgress = createThrottledProgressEmitter(180) + await runWithConcurrency(uniqueFiles, 4, async (sourcePath, index) => { + emitResourceProgress({ + phase: 'exporting', + message: kind === 'video' ? '正在归档视频资源' : '正在归档文件资源', + current: index + 1, + total: uniqueFiles.length, + detail: basename(sourcePath) + }) + const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath) + if (!relativeTarget) return + const archivePath = toArchivePath(join('resources', bucket, relativeTarget)) + const outputPath = join(stagingDir, archivePath) + await this.stagePlainResource(sourcePath, outputPath) + let size = 0 + try { size = statSync(sourcePath).size } catch {} + const entry: BackupResourceEntry = { + kind, + id: relativeTarget, + sourceFileName: basename(sourcePath), + archivePath, + targetRelativePath: relativeTarget, + size + } + resources.push(entry) + }) + + if (resources.length > 0) { + manifest.resources = { + ...(manifest.resources || {}), + [bucket]: resources + } + } + } + + async createBackup(outputPath: string, options: BackupOptions = {}): Promise<{ success: boolean; filePath?: string; manifest?: BackupManifest; error?: string }> { + let stagingDir = '' + try { + emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' }) + const connected = await this.ensureConnected() + if (!connected.success || !connected.wxid || !connected.dbPath || !connected.dbStorage) { + return { success: false, error: connected.error || '数据库未连接' } + } + + stagingDir = await this.createTempDir('weflow-backup-') + const snapshotsDir = join(stagingDir, 'snapshots') + mkdirSync(snapshotsDir, { recursive: true }) + + const dbs = await this.collectDatabases(connected.dbStorage) + const manifest: BackupManifest = { + version: 1, + type: 'weflow-db-snapshots', + createdAt: new Date().toISOString(), + appVersion: app.getVersion(), + source: { + wxid: connected.wxid, + dbRoot: connected.dbPath + }, + databases: [], + options: { + includeImages: options.includeImages === true, + includeVideos: options.includeVideos === true, + includeFiles: options.includeFiles === true + } + } + + const tableJobs: Array<{ db: Omit; table: string; schemaSql: string; snapshotPath: string; outputPath: string }> = [] + for (let index = 0; index < dbs.length; index += 1) { + const db = dbs[index] + emitBackupProgress({ + phase: 'scanning', + message: '正在扫描数据库和表', + current: index + 1, + total: dbs.length, + detail: `${db.kind}:${db.relativePath || db.dbPath || db.id}` + }) + const tablesResult = await wcdbService.listTables(db.kind, db.dbPath) + if (!tablesResult.success || !Array.isArray(tablesResult.tables) || tablesResult.tables.length === 0) continue + const dbDir = join(snapshotsDir, db.id) + mkdirSync(dbDir, { recursive: true }) + const entry: BackupDbEntry = { ...db, tables: [] } + manifest.databases.push(entry) + for (const table of tablesResult.tables) { + const schemaResult = await wcdbService.getTableSchema(db.kind, db.dbPath, table) + if (!schemaResult.success || !schemaResult.schema) continue + const snapshotPath = toArchivePath(join('snapshots', db.id, `${safeName(table)}.wfsnap`)) + tableJobs.push({ + db, + table, + schemaSql: schemaResult.schema, + snapshotPath, + outputPath: join(stagingDir, snapshotPath) + }) + } + } + + let current = 0 + for (const job of tableJobs) { + current++ + emitBackupProgress({ + phase: 'exporting', + message: '正在导出数据库快照', + current, + total: tableJobs.length, + detail: `${job.db.kind}:${job.table}` + }) + const exported = await wcdbService.exportTableSnapshot(job.db.kind, job.db.dbPath, job.table, job.outputPath) + if (!exported.success) { + throw new Error(`${job.db.kind}:${job.table} 导出失败:${exported.error || 'unknown'}`) + } + const dbEntry = manifest.databases.find(item => item.id === job.db.id) + dbEntry?.tables.push({ + name: job.table, + snapshotPath: job.snapshotPath, + rows: exported.rows || 0, + columns: exported.columns || 0, + schemaSql: job.schemaSql + }) + } + + if (options.includeImages === true) { + await this.collectImageResources( + { wxid: connected.wxid, dbStorage: connected.dbStorage }, + stagingDir, + manifest + ) + } + if (options.includeVideos === true) { + await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'video') + } + if (options.includeFiles === true) { + await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'file') + } + + await writeFile(join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8') + mkdirSync(dirname(outputPath), { recursive: true }) + const archiveFiles = await this.listFilesForArchive(stagingDir) + const shouldCompress = !hasResourceOptions(options) + let packed = 0 + const emitPackingProgress = createThrottledProgressEmitter(150) + emitBackupProgress({ phase: 'packing', message: '正在生成备份包', current: 0, total: archiveFiles.length }) + await tar.c({ + gzip: shouldCompress ? { level: 1 } : false, + cwd: stagingDir, + file: outputPath, + portable: true, + noMtime: true, + sync: false, + onWriteEntry: (entry: any) => { + packed += 1 + emitPackingProgress({ + phase: 'packing', + message: '正在写入备份包', + current: Math.min(packed, archiveFiles.length), + total: archiveFiles.length, + detail: String(entry?.path || entry || '') + }) + } + } as any, archiveFiles) + emitBackupProgress({ + phase: 'packing', + message: '正在写入备份包', + current: archiveFiles.length, + total: archiveFiles.length + }) + emitBackupProgress({ phase: 'done', message: '备份完成', current: tableJobs.length, total: tableJobs.length }) + return { success: true, filePath: outputPath, manifest } + } catch (e) { + const error = e instanceof Error ? e.message : String(e) + emitBackupProgress({ phase: 'failed', message: error }) + return { success: false, error } + } finally { + if (stagingDir) { + try { rmSync(stagingDir, { recursive: true, force: true }) } catch {} + } + } + } + + async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> { + let extractDir = '' + try { + emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' }) + extractDir = await this.createTempDir('weflow-backup-inspect-') + await tar.x({ + file: archivePath, + cwd: extractDir, + filter: (entryPath: string) => entryPath.replace(/\\/g, '/') === 'manifest.json' + } as any) + const manifestPath = join(extractDir, 'manifest.json') + if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' } + const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest + if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) { + return { success: false, error: '不支持的备份包格式' } + } + return { success: true, manifest } + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) } + } finally { + if (extractDir) { + try { rmSync(extractDir, { recursive: true, force: true }) } catch {} + } + } + } + + private async streamRestoreArchive( + archivePath: string, + extractDir: string, + manifest: BackupManifest, + connected: { dbStorage: string; wxid?: string }, + startCurrent: number, + total: number + ): Promise<{ current: number; skipped: number }> { + const snapshotPaths = new Set() + for (const db of manifest.databases || []) { + for (const table of db.tables || []) { + const path = normalizeArchivePath(table.snapshotPath) + if (path) snapshotPaths.add(path) + } + } + + const imageByPath = new Map() + for (const image of manifest.resources?.images || []) { + const path = normalizeArchivePath(image.archivePath) + if (path) imageByPath.set(path, image) + } + + const plainByPath = new Map() + for (const resource of [ + ...(manifest.resources?.videos || []), + ...(manifest.resources?.files || []) + ]) { + const path = normalizeArchivePath(resource.archivePath) + if (path) plainByPath.set(path, resource) + } + + const accountDir = dirname(connected.dbStorage) + let current = startCurrent + let skipped = 0 + const pending: Promise[] = [] + const emitRestoreProgress = createThrottledProgressEmitter(160) + await tar.t({ + file: archivePath, + onReadEntry: (entry: any) => { + const entryPath = normalizeArchivePath(entry.path) + if (snapshotPaths.has(entryPath)) { + const outputPath = this.resolveStagingPath(extractDir, entryPath) + if (!outputPath) { + entry.resume() + return + } + pending.push(this.writeTarEntryToFile(entry, outputPath)) + return + } + + const image = imageByPath.get(entryPath) + if (image) { + const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath) + if (!targetPath) { + skipped += 1 + entry.resume() + return + } + current += 1 + emitRestoreProgress({ + phase: 'restoring', + message: '正在写回图片资源', + current, + total, + detail: image.md5 || image.targetRelativePath + }) + if (existsSync(targetPath)) { + skipped += 1 + entry.resume() + return + } + pending.push(this.writeTarEntryToFile(entry, targetPath)) + return + } + + const resource = plainByPath.get(entryPath) + if (resource) { + const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath) + current += 1 + emitRestoreProgress({ + phase: 'restoring', + message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源', + current, + total, + detail: resource.targetRelativePath + }) + if (!targetPath || existsSync(targetPath)) { + skipped += 1 + entry.resume() + return + } + pending.push(this.writeTarEntryToFile(entry, targetPath)) + return + } + + entry.resume() + } + } as any) + + await Promise.all(pending) + return { current, skipped } + } + + async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> { + let extractDir = '' + try { + emitBackupProgress({ phase: 'inspecting', message: '正在读取备份信息' }) + extractDir = await this.createTempDir('weflow-backup-restore-') + await tar.x({ + file: archivePath, + cwd: extractDir, + filter: (entryPath: string) => normalizeArchivePath(entryPath) === 'manifest.json' + } as any) + const manifestPath = join(extractDir, 'manifest.json') + if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' } + const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest + if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) { + return { success: false, error: '不支持的备份包格式' } + } + const targetWxid = String(manifest.source?.wxid || '').trim() + if (!targetWxid) return { success: false, error: '备份包缺少来源账号 wxid,无法定位目标账号目录' } + + emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetWxid }) + const connected = await this.ensureConnected(targetWxid) + if (!connected.success || !connected.dbStorage) return { success: false, error: connected.error || '数据库未连接' } + + const tableJobs = manifest.databases.flatMap(db => db.tables.map(table => ({ db, table }))) + const imageJobs = manifest.resources?.images || [] + const plainResourceJobs = [ + ...(manifest.resources?.videos || []), + ...(manifest.resources?.files || []) + ] + const totalRestoreJobs = tableJobs.length + imageJobs.length + plainResourceJobs.length + let inserted = 0 + let ignored = 0 + let skipped = 0 + let current = 0 + if (imageJobs.length > 0 || plainResourceJobs.length > 0 || tableJobs.length > 0) { + emitBackupProgress({ + phase: 'inspecting', + message: '正在按需读取备份包', + current: 0, + total: totalRestoreJobs, + detail: archivePath + }) + const streamed = await this.streamRestoreArchive( + archivePath, + extractDir, + manifest, + { dbStorage: connected.dbStorage, wxid: connected.wxid }, + 0, + totalRestoreJobs + ) + current = streamed.current + skipped += streamed.skipped + } + + for (const job of tableJobs) { + current++ + const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db) + if (targetDbPath === null) { + skipped++ + continue + } + if (!job.table.schemaSql) { + skipped++ + continue + } + + emitBackupProgress({ + phase: 'restoring', + message: '正在通过 WCDB 写入数据库', + current, + total: totalRestoreJobs, + detail: `${job.db.kind}:${job.table.name}` + }) + const inputPath = this.resolveExtractedPath(extractDir, job.table.snapshotPath) + if (!inputPath || !existsSync(inputPath)) { + skipped++ + continue + } + mkdirSync(dirname(targetDbPath), { recursive: true }) + const restored = await wcdbService.importTableSnapshotWithSchema( + job.db.kind, + targetDbPath, + job.table.name, + inputPath, + job.table.schemaSql + ) + if (!restored.success) { + skipped++ + continue + } + inserted += restored.inserted || 0 + ignored += restored.ignored || 0 + if (current % 4 === 0) await delay() + } + + emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs }) + return { success: true, inserted, ignored, skipped } + } catch (e) { + const error = e instanceof Error ? e.message : String(e) + emitBackupProgress({ phase: 'failed', message: error }) + return { success: false, error } + } finally { + if (extractDir) { + try { rmSync(extractDir, { recursive: true, force: true }) } catch {} + } + } + } +} + +export const backupService = new BackupService() diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 047a42e..628ecf0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -666,6 +666,9 @@ class ChatService { if (this.connected && wcdbService.isReady()) { return { success: true } } + if (!wcdbService.isReady()) { + this.monitorSetup = false + } const result = await this.connect() if (!result.success) { this.connected = false @@ -709,6 +712,7 @@ class ChatService { console.error('ChatService: 关闭数据库失败:', e) } this.connected = false + this.monitorSetup = false } /** @@ -745,8 +749,12 @@ class ChatService { try { const connectResult = await this.ensureConnected() if (!connectResult.success) return { success: false, error: connectResult.error } - const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) - return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds) + const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds) + const result = validIds.length > 0 + ? await wcdbService.checkMessageAntiRevokeTriggers(validIds) + : { success: true, rows: [] } + if (!result.success) return result + return { success: true, rows: [...(result.rows || []), ...invalidRows] } } catch (e) { return { success: false, error: String(e) } } @@ -760,8 +768,12 @@ class ChatService { try { const connectResult = await this.ensureConnected() if (!connectResult.success) return { success: false, error: connectResult.error } - const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) - return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds) + const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds) + const result = validIds.length > 0 + ? await wcdbService.installMessageAntiRevokeTriggers(validIds) + : { success: true, rows: [] } + if (!result.success) return result + return { success: true, rows: [...(result.rows || []), ...invalidRows] } } catch (e) { return { success: false, error: String(e) } } @@ -775,8 +787,12 @@ class ChatService { try { const connectResult = await this.ensureConnected() if (!connectResult.success) return { success: false, error: connectResult.error } - const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) - return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds) + const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds) + const result = validIds.length > 0 + ? await wcdbService.uninstallMessageAntiRevokeTriggers(validIds) + : { success: true, rows: [] } + if (!result.success) return result + return { success: true, rows: [...(result.rows || []), ...invalidRows] } } catch (e) { return { success: false, error: String(e) } } @@ -934,6 +950,191 @@ class ChatService { } } + async getAntiRevokeSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { + try { + const result = await this.getSessions() + if (!result.success || !Array.isArray(result.sessions)) { + return { success: false, error: result.error || '获取会话失败' } + } + + return { + success: true, + sessions: result.sessions.filter((session) => !String(session.username || '').startsWith('gh_')) + } + } catch (e) { + console.error('ChatService: 获取防撤回会话列表失败:', e) + return { success: false, error: String(e) } + } + } + + private getSessionUsername(row: Record): string { + return String( + row.username || + row.user_name || + row.userName || + row.usrName || + row.UsrName || + row.talker || + row.talker_id || + row.talkerId || + '' + ).trim() + } + + private isAntiRevokeContactRow(username: string, row: Record): boolean { + if (!username) return false + if (username.endsWith('@chatroom')) return true + if (username.startsWith('gh_')) return false + + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN) + const lowered = username.toLowerCase() + if (this.isEnterpriseOpenimUsername(username)) { + return this.isAllowedEnterpriseOpenimByLocalType(username, localType) + } + if (lowered.startsWith('weixin') && lowered !== 'weixin') return true + return localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username) + } + + private async loadAntiRevokeContactMap(usernames: string[]): Promise> { + const targets = Array.from(new Set((usernames || []).map((value) => String(value || '').trim()).filter(Boolean))) + const map = new Map() + if (targets.length === 0) return map + + try { + const contactResult = await wcdbService.getContactsCompact(targets) + if (!contactResult.success || !Array.isArray(contactResult.contacts)) return map + + for (const row of contactResult.contacts as Record[]) { + const username = String(row.username || '').trim() + if (!username || !this.isAntiRevokeContactRow(username, row)) continue + map.set(username, { + displayName: String(row.remark || row.nick_name || row.nickName || row.alias || username).trim() + }) + } + } catch { + return map + } + + return map + } + + private async hasAntiRevokeMessageTables(sessionId: string): Promise { + try { + const tableStatsResult = await wcdbService.getMessageTableStats(sessionId) + if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) return false + return tableStatsResult.tables.some((row: Record) => { + const tableName = String(row.table_name || row.tableName || '').trim() + return tableName.length > 0 + }) + } catch { + return false + } + } + + private async buildAntiRevokeSessionsFromRows(rows: Record[]): Promise { + if (rows.length > 0 && (rows[0]._error || rows[0]._info)) return [] + + const candidateRows: Array<{ username: string; row: Record }> = [] + const privateCandidateIds: string[] = [] + const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) => this.getSessionUsername(row))) + + for (const row of rows) { + const username = this.getSessionUsername(row) + if (!username) continue + + let sessionLocalType = this.getSessionLocalType(row) + if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) { + sessionLocalType = openimLocalTypeMap.get(username) + } + if (!this.shouldKeepSession(username, sessionLocalType)) continue + + if (username.endsWith('@chatroom')) { + candidateRows.push({ username, row }) + } else { + privateCandidateIds.push(username) + candidateRows.push({ username, row }) + } + } + + const contactMap = await this.loadAntiRevokeContactMap(privateCandidateIds) + const sessions: ChatSession[] = [] + const myWxid = this.configService.get('myWxid') + const now = Date.now() + + for (const { username, row } of candidateRows) { + const isGroup = username.endsWith('@chatroom') + if (!isGroup && !contactMap.has(username)) continue + if (!await this.hasAntiRevokeMessageTables(username)) continue + + const sortTs = parseInt( + row.sort_timestamp || + row.sortTimestamp || + row.sort_time || + row.sortTime || + '0', + 10 + ) + const lastTs = parseInt( + row.last_timestamp || + row.lastTimestamp || + row.last_msg_time || + row.lastMsgTime || + String(sortTs), + 10 + ) + const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') + const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) + const cached = this.avatarCache.get(username) + const contact = contactMap.get(username) + + const session: ChatSession = { + username, + type: parseInt(row.type || '0', 10), + unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10), + summary: summary || this.getMessageTypeLabel(lastMsgType), + sortTimestamp: sortTs, + lastTimestamp: lastTs, + lastMsgType, + displayName: contact?.displayName || cached?.displayName || username, + avatarUrl: cached?.avatarUrl, + lastMsgSender: row.last_msg_sender, + lastSenderDisplayName: row.last_sender_display_name, + selfWxid: myWxid + } + + const cachedStatus = this.sessionStatusCache.get(username) + if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) { + session.isFolded = cachedStatus.isFolded + session.isMuted = cachedStatus.isMuted + } + + sessions.push(session) + } + + return sessions + } + + private async filterAntiRevokeSessionIds(sessionIds: string[]): Promise<{ + validIds: string[] + invalidRows: Array<{ sessionId: string; success: false; error: string }> + }> { + const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + if (normalizedIds.length === 0) return { validIds: [], invalidRows: [] } + + const sessionsResult = await this.getAntiRevokeSessions() + const allowedIds = new Set((sessionsResult.sessions || []).map((session) => session.username)) + const validIds = normalizedIds.filter((sessionId) => allowedIds.has(sessionId)) + const invalidRows = normalizedIds + .filter((sessionId) => !allowedIds.has(sessionId)) + .map((sessionId) => ({ + sessionId, + success: false as const, + error: '该会话不是联系人或群聊,或不存在可安装防撤回的消息表' + })) + + return { validIds, invalidRows } + } + private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise { const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean)) try { @@ -4609,6 +4810,7 @@ class ChatService { const createTime = this.getRowTimestampSeconds(row, ['create_time', 'createTime', 'msg_time', 'msgTime', 'time'], 0) const sortSeq = this.getRowInt(row, ['sort_seq'], createTime > 0 ? createTime * 1000 : 0) const localId = this.getRowInt(row, ['local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) const serverId = this.getRowInt(row, ['server_id'], 0) const content = this.decodeMessageContent(row.message_content, row.compress_content) @@ -4635,6 +4837,7 @@ class ChatService { }), localId, serverId, + serverIdRaw, localType, createTime, sortSeq, diff --git a/electron/services/config.ts b/electron/services/config.ts index 35a382d..ff06ccd 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -36,6 +36,7 @@ interface ConfigSchema { language: string logEnabled: boolean launchAtStartup?: boolean + silentStartup?: boolean llmModelPath: string whisperModelName: string whisperModelDir: string @@ -163,6 +164,7 @@ export class ConfigService { themeId: 'cloud-dancer', language: 'zh-CN', logEnabled: false, + silentStartup: false, llmModelPath: '', whisperModelName: 'base', whisperModelDir: '', diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 9bca848..a3c730b 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -200,6 +200,8 @@ interface MediaSourceResolution { interface ExportTaskControl { shouldPause?: () => boolean shouldStop?: () => boolean + recordCreatedFile?: (filePath: string) => void + recordCreatedDir?: (dirPath: string) => void } interface ExportStatsResult { @@ -279,6 +281,7 @@ class ExportService { private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000 private readonly exportStatsCacheMaxEntries = 16 private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED' + private readonly PAUSE_ERROR_CODE = 'WEFLOW_EXPORT_PAUSE_REQUESTED' private mediaFileCachePopulatePending = new Map>() private mediaFileCacheReadyDirs = new Set() private mediaExportTelemetry: MediaExportTelemetry | null = null @@ -311,6 +314,12 @@ class ExportService { return error } + private createPauseError(): Error { + const error = new Error('导出任务已暂停') + ;(error as Error & { code?: string }).code = this.PAUSE_ERROR_CODE + return error + } + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void { this.runtimeConfig = config } @@ -453,10 +462,42 @@ class ExportService { return false } + private isPauseError(error: unknown): boolean { + if (!error) return false + if (typeof error === 'string') { + return error.includes(this.PAUSE_ERROR_CODE) || error.includes('导出任务已暂停') + } + if (error instanceof Error) { + const code = (error as Error & { code?: string }).code + return code === this.PAUSE_ERROR_CODE || error.message.includes(this.PAUSE_ERROR_CODE) || error.message.includes('导出任务已暂停') + } + return false + } + private throwIfStopRequested(control?: ExportTaskControl): void { if (control?.shouldStop?.()) { throw this.createStopError() } + if (control?.shouldPause?.()) { + throw this.createPauseError() + } + } + + private async ensureExportDir(dirPath: string, control?: ExportTaskControl, dirCache?: Set): Promise { + if (dirCache?.has(dirPath)) return + const existed = await this.pathExists(dirPath) + await fs.promises.mkdir(dirPath, { recursive: true }) + dirCache?.add(dirPath) + if (!existed) { + control?.recordCreatedDir?.(dirPath) + } + } + + private async recordCreatedFileBeforeWrite(filePath: string, control?: ExportTaskControl): Promise { + if (!control?.recordCreatedFile) return + if (!await this.pathExists(filePath)) { + control.recordCreatedFile(filePath) + } } private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number { @@ -850,8 +891,10 @@ class ExportService { private async copyMediaWithCacheAndDedup( kind: 'image' | 'video' | 'emoji', sourcePath: string, - destPath: string + destPath: string, + control?: ExportTaskControl ): Promise<{ success: boolean; code?: string }> { + const existedBeforeCopy = await this.pathExists(destPath) const resolved = await this.resolvePreferredMediaSource(kind, sourcePath) if (resolved.cacheHit) { this.noteMediaTelemetry({ cacheHitFiles: 1 }) @@ -870,6 +913,9 @@ class ExportService { dedupReuseFiles: 1, bytesWritten: resolved.fileStat?.size || 0 }) + if (!existedBeforeCopy) { + control?.recordCreatedFile?.(destPath) + } return { success: true } } } @@ -886,6 +932,9 @@ class ExportService { doneFiles: 1, bytesWritten: resolved.fileStat?.size || 0 }) + if (!existedBeforeCopy) { + control?.recordCreatedFile?.(destPath) + } return { success: true } } @@ -3962,6 +4011,7 @@ class ExportService { includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean dirCache?: Set + control?: ExportTaskControl } ): Promise { const localType = msg.localType @@ -3973,7 +4023,8 @@ class ExportService { sessionId, mediaRootDir, mediaRelativePrefix, - options.dirCache + options.dirCache, + options.control ) if (result) { } @@ -3983,7 +4034,7 @@ class ExportService { // 语音消息 if (localType === 34) { if (options.exportVoices) { - return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) + return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control) } if (options.exportVoiceAsText) { return null @@ -3992,7 +4043,7 @@ class ExportService { // 动画表情 if (localType === 47 && options.exportEmojis) { - const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) + const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control) if (result) { } return result @@ -4005,7 +4056,8 @@ class ExportService { mediaRootDir, mediaRelativePrefix, options.dirCache, - options.includeVideoPoster === true + options.includeVideoPoster === true, + options.control ) } @@ -4015,7 +4067,8 @@ class ExportService { mediaRootDir, mediaRelativePrefix, options.maxFileSizeMb, - options.dirCache + options.dirCache, + options.control ) } @@ -4030,14 +4083,12 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - dirCache?: Set + dirCache?: Set, + control?: ExportTaskControl ): Promise { try { const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') - if (!dirCache?.has(imagesDir)) { - await fs.promises.mkdir(imagesDir, { recursive: true }) - dirCache?.add(imagesDir) - } + await this.ensureExportDir(imagesDir, control, dirCache) const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise => { if (!imageMd5 && !imageDatName) return null @@ -4123,6 +4174,7 @@ class ExportService { const destPath = path.join(imagesDir, fileName) const buffer = Buffer.from(base64Data, 'base64') + await this.recordCreatedFileBeforeWrite(destPath, control) await fs.promises.writeFile(destPath, buffer) this.noteMediaTelemetry({ doneFiles: 1, @@ -4142,7 +4194,7 @@ class ExportService { const ext = path.extname(sourcePath) || '.jpg' const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath) + const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control) if (!copied.success) { if (copied.code === 'ENOENT') { console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) @@ -4261,14 +4313,12 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - dirCache?: Set + dirCache?: Set, + control?: ExportTaskControl ): Promise { try { const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices') - if (!dirCache?.has(voicesDir)) { - await fs.promises.mkdir(voicesDir, { recursive: true }) - dirCache?.add(voicesDir) - } + await this.ensureExportDir(voicesDir, control, dirCache) const msgId = String(msg.localId) const safeSession = this.cleanAccountDirName(sessionId) @@ -4300,6 +4350,7 @@ class ExportService { // voiceResult.data 是 base64 编码的 wav 数据 const wavBuffer = Buffer.from(voiceResult.data, 'base64') + await this.recordCreatedFileBeforeWrite(destPath, control) await fs.promises.writeFile(destPath, wavBuffer) this.noteMediaTelemetry({ doneFiles: 1, @@ -4338,14 +4389,12 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - dirCache?: Set + dirCache?: Set, + control?: ExportTaskControl ): Promise { try { const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis') - if (!dirCache?.has(emojisDir)) { - await fs.promises.mkdir(emojisDir, { recursive: true }) - dirCache?.add(emojisDir) - } + await this.ensureExportDir(emojisDir, control, dirCache) // 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑) const localPath = await chatService.downloadEmojiFile(msg) @@ -4359,7 +4408,7 @@ class ExportService { const key = msg.emojiMd5 || String(msg.localId) const fileName = `${key}${ext}` const destPath = path.join(emojisDir, fileName) - const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath) + const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control) if (!copied.success) return null return { @@ -4381,7 +4430,8 @@ class ExportService { mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set, - includePoster = false + includePoster = false, + control?: ExportTaskControl ): Promise { try { let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase() @@ -4404,16 +4454,13 @@ class ExportService { if (!videoInfo) return null const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') - if (!dirCache?.has(videosDir)) { - await fs.promises.mkdir(videosDir, { recursive: true }) - dirCache?.add(videosDir) - } + await this.ensureExportDir(videosDir, control, dirCache) const sourcePath = videoInfo.videoUrl const fileName = path.basename(sourcePath) const destPath = path.join(videosDir, fileName) - const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath) + const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control) if (!copied.success) return null return { @@ -4864,7 +4911,8 @@ class ExportService { mediaRootDir: string, mediaRelativePrefix: string, maxFileSizeMb?: number, - dirCache?: Set + dirCache?: Set, + control?: ExportTaskControl ): Promise { try { const fileNameRaw = String(msg?.fileName || '').trim() @@ -4872,10 +4920,7 @@ class ExportService { const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw) const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir) - if (!dirCache?.has(fileDir)) { - await fs.promises.mkdir(fileDir, { recursive: true }) - dirCache?.add(fileDir) - } + await this.ensureExportDir(fileDir, control, dirCache) const candidates = await this.resolveFileAttachmentCandidates(msg) if (candidates.length === 0) { @@ -4919,6 +4964,7 @@ class ExportService { const messageId = String(msg?.localId || Date.now()) const destFileName = `${messageId}_${safeBaseName}` const destPath = path.join(fileDir, destFileName) + const existedBeforeCopy = await this.pathExists(destPath) const copied = await this.copyFileOptimized(selected.sourcePath, destPath) if (!copied.success) { this.recordFileAttachmentMiss(msg, '附件复制失败', { @@ -4929,6 +4975,9 @@ class ExportService { return null } + if (!existedBeforeCopy) { + control?.recordCreatedFile?.(destPath) + } this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size }) return { relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName), @@ -5884,16 +5933,15 @@ class ExportService { */ private async exportAvatarsToFiles( members: Array<{ username: string; avatarUrl?: string }>, - outputDir: string + outputDir: string, + control?: ExportTaskControl ): Promise> { const result = new Map() if (members.length === 0) return result // 创建 avatars 子目录 const avatarsDir = path.join(outputDir, 'avatars') - if (!fs.existsSync(avatarsDir)) { - fs.mkdirSync(avatarsDir, { recursive: true }) - } + await this.ensureExportDir(avatarsDir, control) const AVATAR_CONCURRENCY = 8 await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => { @@ -5934,6 +5982,7 @@ class ExportService { try { await fs.promises.access(avatarPath) } catch { + await this.recordCreatedFileBeforeWrite(avatarPath, control) await fs.promises.writeFile(avatarPath, data) } @@ -6202,7 +6251,8 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache + dirCache: mediaDirCache, + control }) mediaCache.set(mediaKey, mediaItem) } @@ -6551,9 +6601,11 @@ class ExportService { lines.push(JSON.stringify({ _type: 'message', ...message })) } this.throwIfStopRequested(control) + await this.recordCreatedFileBeforeWrite(outputPath, control) await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8') } else { this.throwIfStopRequested(control) + await this.recordCreatedFileBeforeWrite(outputPath, control) await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') } @@ -6573,6 +6625,9 @@ class ExportService { if (this.isStopError(e)) { return { success: false, error: '导出任务已停止' } } + if (this.isPauseError(e)) { + return { success: false, error: '导出任务已暂停' } + } return { success: false, error: String(e) } } } @@ -6706,7 +6761,8 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache + dirCache: mediaDirCache, + control }) mediaCache.set(mediaKey, mediaItem) } @@ -7256,6 +7312,7 @@ class ExportService { } this.throwIfStopRequested(control) + await this.recordCreatedFileBeforeWrite(outputPath, control) await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8') } else { const detailedExport: any = { @@ -7279,6 +7336,7 @@ class ExportService { } this.throwIfStopRequested(control) + await this.recordCreatedFileBeforeWrite(outputPath, control) await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') } @@ -7298,6 +7356,9 @@ class ExportService { if (this.isStopError(e)) { return { success: false, error: '导出任务已停止' } } + if (this.isPauseError(e)) { + return { success: false, error: '导出任务已暂停' } + } return { success: false, error: String(e) } } } @@ -7571,7 +7632,8 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache + dirCache: mediaDirCache, + control }) mediaCache.set(mediaKey, mediaItem) } @@ -7835,6 +7897,7 @@ class ExportService { // 写入文件 this.throwIfStopRequested(control) + await this.recordCreatedFileBeforeWrite(outputPath, control) await workbook.xlsx.writeFile(outputPath) onProgress?.({ @@ -7853,6 +7916,9 @@ class ExportService { if (this.isStopError(e)) { return { success: false, error: '导出任务已停止' } } + if (this.isPauseError(e)) { + return { success: false, error: '导出任务已暂停' } + } // 处理文件被占用的错误 if (e instanceof Error) { if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) { @@ -8134,6 +8200,9 @@ class ExportService { if (this.isStopError(e)) { return { success: false, error: '导出任务已停止' } } + if (this.isPauseError(e)) { + return { success: false, error: '导出任务已暂停' } + } if (e instanceof Error) { if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) { return { success: false, error: '文件已经打开,请关闭后再导出' } @@ -8315,7 +8384,8 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache + dirCache: mediaDirCache, + control }) mediaCache.set(mediaKey, mediaItem) } @@ -8382,6 +8452,7 @@ class ExportService { exportedMessages: 0 }) + await this.recordCreatedFileBeforeWrite(outputPath, control) const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) const writeChunk = async (chunk: string): Promise => { await new Promise((resolve, _reject) => { @@ -8567,6 +8638,9 @@ class ExportService { if (this.isStopError(e)) { return { success: false, error: '导出任务已停止' } } + if (this.isPauseError(e)) { + return { success: false, error: '导出任务已暂停' } + } return { success: false, error: String(e) } } } @@ -8710,7 +8784,8 @@ class ExportService { maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache + dirCache: mediaDirCache, + control }) mediaCache.set(mediaKey, mediaItem) } @@ -8777,6 +8852,7 @@ class ExportService { exportedMessages: 0 }) + await this.recordCreatedFileBeforeWrite(outputPath, control) const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) const writeChunk = async (chunk: string): Promise => { await new Promise((resolve, _reject) => { @@ -8929,6 +9005,9 @@ class ExportService { if (this.isStopError(e)) { return { success: false, error: '导出任务已停止' } } + if (this.isPauseError(e)) { + return { success: false, error: '导出任务已暂停' } + } return { success: false, error: String(e) } } } @@ -9153,7 +9232,8 @@ class ExportService { includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, exportVideos: options.exportVideos, - dirCache: mediaDirCache + dirCache: mediaDirCache, + control }) mediaCache.set(mediaKey, mediaItem) } @@ -9224,7 +9304,8 @@ class ExportService { { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } ], - path.dirname(outputPath) + path.dirname(outputPath), + control ) : new Map() @@ -9241,6 +9322,7 @@ class ExportService { // ================= BEGIN STREAM WRITING ================= const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup) const htmlStyles = this.loadExportHtmlStyles() + await this.recordCreatedFileBeforeWrite(outputPath, control) const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) const writePromise = (str: string) => { @@ -9605,6 +9687,9 @@ class ExportService { if (this.isStopError(e)) { return { success: false, error: '导出任务已停止' } } + if (this.isPauseError(e)) { + return { success: false, error: '导出任务已暂停' } + } return { success: false, error: String(e) } } } @@ -9908,7 +9993,7 @@ class ExportService { const reservedOutputPaths = new Set() const ensureTaskDir = async (dirPath: string) => { if (createdTaskDirs.has(dirPath)) return - await fs.promises.mkdir(dirPath, { recursive: true }) + await this.ensureExportDir(dirPath, control) createdTaskDirs.add(dirPath) } await ensureTaskDir(exportBaseDir) @@ -10085,7 +10170,7 @@ class ExportService { } } - const runOne = async (sessionId: string): Promise<'done' | 'stopped'> => { + const runOne = async (sessionId: string): Promise<'done' | 'stopped' | 'paused'> => { try { this.throwIfStopRequested(control) const sessionInfo = await this.getContactInfo(sessionId) @@ -10234,6 +10319,10 @@ class ExportService { activeSessionRatios.delete(sessionId) return 'stopped' } + if (!result.success && this.isPauseError(result.error)) { + activeSessionRatios.delete(sessionId) + return 'paused' + } if (result.success) { successCount++ @@ -10269,6 +10358,10 @@ class ExportService { activeSessionRatios.delete(sessionId) return 'stopped' } + if (this.isPauseError(error)) { + activeSessionRatios.delete(sessionId) + return 'paused' + } throw error } } @@ -10294,6 +10387,11 @@ class ExportService { queue.unshift(sessionId) break } + if (runState === 'paused') { + pauseRequested = true + queue.unshift(sessionId) + break + } } } else { const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => { @@ -10315,6 +10413,11 @@ class ExportService { queue.unshift(sessionId) break } + if (runState === 'paused') { + pauseRequested = true + queue.unshift(sessionId) + break + } } }) await Promise.all(workers) @@ -10333,7 +10436,7 @@ class ExportService { sessionOutputPaths } } - if (pauseRequested && pendingSessionIds.length > 0) { + if (pauseRequested) { return { success: true, successCount, diff --git a/electron/services/exportTaskControlService.ts b/electron/services/exportTaskControlService.ts new file mode 100644 index 0000000..fc31244 --- /dev/null +++ b/electron/services/exportTaskControlService.ts @@ -0,0 +1,210 @@ +import * as path from 'path' +import { rm, rmdir } from 'fs/promises' + +export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested' + +export interface ExportTaskControlHooks { + shouldPause: () => boolean + shouldStop: () => boolean + recordCreatedFile: (filePath: string) => void + recordCreatedDir: (dirPath: string) => void +} + +interface ExportTaskManifest { + outputDir: string + files: Set + dirs: Set +} + +interface ExportTaskControlRecord { + state: ExportTaskControlState + manifest: ExportTaskManifest + createdAt: number + updatedAt: number +} + +export interface ExportTaskCleanupResult { + success: boolean + filesDeleted: number + dirsDeleted: number + error?: string +} + +class ExportTaskControlService { + private tasks = new Map() + + createControl(taskId: string, outputDir: string): ExportTaskControlHooks { + this.registerTask(taskId, outputDir) + return { + shouldPause: () => this.getState(taskId) === 'pause_requested', + shouldStop: () => this.getState(taskId) === 'cancel_requested', + recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath), + recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath) + } + } + + registerTask(taskId: string, outputDir: string): void { + const normalizedTaskId = this.normalizeTaskId(taskId) + if (!normalizedTaskId) return + + const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.') + const existing = this.tasks.get(normalizedTaskId) + if (existing) { + existing.state = 'running' + existing.updatedAt = Date.now() + if (!existing.manifest.outputDir) { + existing.manifest.outputDir = normalizedOutputDir + } + return + } + + this.tasks.set(normalizedTaskId, { + state: 'running', + manifest: { + outputDir: normalizedOutputDir, + files: new Set(), + dirs: new Set() + }, + createdAt: Date.now(), + updatedAt: Date.now() + }) + } + + pauseTask(taskId: string): boolean { + return this.setState(taskId, 'pause_requested') + } + + resumeTask(taskId: string): boolean { + return this.setState(taskId, 'running') + } + + cancelTask(taskId: string): boolean { + return this.setState(taskId, 'cancel_requested') + } + + getState(taskId: string): ExportTaskControlState | null { + const normalizedTaskId = this.normalizeTaskId(taskId) + if (!normalizedTaskId) return null + return this.tasks.get(normalizedTaskId)?.state || null + } + + releaseTask(taskId: string): void { + const normalizedTaskId = this.normalizeTaskId(taskId) + if (!normalizedTaskId) return + this.tasks.delete(normalizedTaskId) + } + + recordCreatedFile(taskId: string, filePath: string): void { + const task = this.getTaskForManifestWrite(taskId, filePath) + if (!task) return + task.manifest.files.add(path.resolve(filePath)) + task.updatedAt = Date.now() + } + + recordCreatedDir(taskId: string, dirPath: string): void { + const task = this.getTaskForManifestWrite(taskId, dirPath) + if (!task) return + task.manifest.dirs.add(path.resolve(dirPath)) + task.updatedAt = Date.now() + } + + async cleanupTask(taskId: string): Promise { + const normalizedTaskId = this.normalizeTaskId(taskId) + const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined + if (!task) { + return { success: true, filesDeleted: 0, dirsDeleted: 0 } + } + + const outputDir = task.manifest.outputDir + let filesDeleted = 0 + let dirsDeleted = 0 + const errors: string[] = [] + + const files = Array.from(task.manifest.files) + .filter(filePath => this.isInsideOutputDir(filePath, outputDir)) + .sort((a, b) => b.length - a.length) + + for (const filePath of files) { + try { + await rm(filePath, { force: true, recursive: false }) + filesDeleted++ + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code !== 'ENOENT') { + errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + + const dirs = Array.from(task.manifest.dirs) + .filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir)) + .sort((a, b) => b.length - a.length) + + for (const dirPath of dirs) { + try { + await rmdir(dirPath) + dirsDeleted++ + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') { + errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + + if (errors.length === 0) { + this.releaseTask(normalizedTaskId) + return { success: true, filesDeleted, dirsDeleted } + } + + return { + success: false, + filesDeleted, + dirsDeleted, + error: errors.slice(0, 3).join('; ') + } + } + + private setState(taskId: string, state: ExportTaskControlState): boolean { + const normalizedTaskId = this.normalizeTaskId(taskId) + if (!normalizedTaskId) return false + const task = this.tasks.get(normalizedTaskId) + if (!task) return false + task.state = state + task.updatedAt = Date.now() + return true + } + + private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null { + const normalizedTaskId = this.normalizeTaskId(taskId) + if (!normalizedTaskId) return null + const task = this.tasks.get(normalizedTaskId) + if (!task) return null + if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) { + return null + } + return task + } + + private isInsideOutputDir(targetPath: string, outputDir: string): boolean { + const resolvedTarget = path.resolve(targetPath) + const resolvedOutputDir = path.resolve(outputDir) + const relativePath = path.relative(resolvedOutputDir, resolvedTarget) + return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath) + } + + private isSamePath(left: string, right: string): boolean { + const resolvedLeft = path.resolve(left) + const resolvedRight = path.resolve(right) + if (process.platform === 'win32') { + return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() + } + return resolvedLeft === resolvedRight + } + + private normalizeTaskId(taskId: string): string { + return String(taskId || '').trim() + } +} + +export const exportTaskControlService = new ExportTaskControlService() diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 863ed80..1e9b014 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -26,7 +26,7 @@ interface ChatLabHeader { interface ChatLabMeta { name: string platform: string - type: 'group' | 'private' + type: ApiSessionType groupId?: string groupAvatar?: string ownerId?: string @@ -68,6 +68,7 @@ interface ApiMediaOptions { } type MediaKind = 'image' | 'voice' | 'video' | 'emoji' +type ApiSessionType = 'group' | 'private' | 'channel' | 'other' interface ApiExportedMedia { kind: MediaKind @@ -781,6 +782,17 @@ class HttpService { } } + private getApiSessionType(username: string): ApiSessionType { + const normalized = String(username || '').trim() + const lowered = normalized.toLowerCase() + if (!normalized) return 'other' + if (lowered.endsWith('@chatroom')) return 'group' + if (lowered.startsWith('gh_')) return 'channel' + if (lowered.includes('@openim')) return 'channel' + if (lowered.startsWith('weixin') && lowered !== 'weixin') return 'channel' + return 'private' + } + private async handleMessages(url: URL, res: http.ServerResponse): Promise { const talker = (url.searchParams.get('talker') || '').trim() const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) @@ -910,7 +922,7 @@ class HttpService { id: s.username, name: s.displayName || s.username, platform: 'wechat', - type: s.username.endsWith('@chatroom') ? 'group' : 'private', + type: this.getApiSessionType(s.username), messageCount: s.messageCountHint || undefined, lastMessageAt: s.lastTimestamp })) @@ -925,6 +937,7 @@ class HttpService { username: s.username, displayName: s.displayName, type: s.type, + sessionType: this.getApiSessionType(s.username), lastTimestamp: s.lastTimestamp, unreadCount: s.unreadCount })) @@ -1532,7 +1545,7 @@ class HttpService { talker, String(msg.localId), msg.createTime || undefined, - msg.serverId || undefined + this.getMessageServerId(msg) || undefined ) if (result.success && result.data) { const fileName = `voice_${msg.localId}.wav` @@ -1586,9 +1599,11 @@ class HttpService { } private toApiMessage(msg: Message, media?: ApiExportedMedia): Record { + const serverId = this.getMessageServerId(msg) + return { localId: msg.localId, - serverId: msg.serverId, + serverId: serverId || '0', localType: msg.localType, createTime: msg.createTime, sortSeq: msg.sortSeq, @@ -1604,6 +1619,27 @@ class HttpService { } } + private getMessageServerId(msg: Message): string { + const raw = this.normalizeUnsignedIntToken(msg.serverIdRaw) + if (raw && raw !== '0') return raw + + const fallback = this.normalizeUnsignedIntToken(msg.serverId) + return fallback && fallback !== '0' ? fallback : '' + } + + private normalizeUnsignedIntToken(value: unknown): string { + if (value === null || value === undefined) return '' + const text = String(value).trim() + if (!text) return '' + if (/^\d+$/.test(text)) { + return text.replace(/^0+(?=\d)/, '') + } + + const numeric = Number(value) + if (!Number.isFinite(numeric) || numeric <= 0) return '' + return String(Math.floor(numeric)) + } + /** * 解析时间参数 * 支持 YYYYMMDD 格式,返回秒级时间戳 @@ -1868,7 +1904,7 @@ class HttpService { timestamp: msg.createTime, type: this.mapMessageType(msg.localType, msg), content: this.getMessageContent(msg), - platformMessageId: msg.serverId ? String(msg.serverId) : undefined, + platformMessageId: this.getMessageServerId(msg) || undefined, mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined } }) @@ -1882,7 +1918,7 @@ class HttpService { meta: { name: talkerName, platform: 'wechat', - type: isGroup ? 'group' : 'private', + type: this.getApiSessionType(talkerId), groupId: isGroup ? talkerId : undefined, groupAvatar: isGroup ? sessionAvatarInfo?.avatarUrl : undefined, ownerId: myWxid || undefined @@ -2045,6 +2081,12 @@ class HttpService { * 获取消息内容 */ private getMessageContent(msg: Message): string | null { + const normalizeTextContent = (value: string | null | undefined): string | null => { + const text = String(value || '') + if (!text) return null + return text.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '').trim() + } + if (msg.localType === 49) { return this.getType49Content(msg) } @@ -2057,7 +2099,7 @@ class HttpService { // 根据类型返回占位符 switch (msg.localType) { case 1: - return msg.rawContent || null + return normalizeTextContent(msg.parsedContent || msg.rawContent) case 3: return '[图片]' case 34: @@ -2073,7 +2115,7 @@ class HttpService { case 49: return this.getType49Content(msg) default: - return msg.rawContent || null + return normalizeTextContent(msg.parsedContent || msg.rawContent) || null } } diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index e4b5088..b67a73b 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -167,7 +167,7 @@ export class KeyServiceLinux { await new Promise(r => setTimeout(r, 2000)) - return await this.getDbKey(pid, onStatus) + return await this.getDbKey(pid, onStatus, timeoutMs) } catch (err: any) { console.error('[Debug] 自动获取流程彻底崩溃:', err); const errMsg = '自动获取微信 PID 失败: ' + err.message @@ -176,7 +176,7 @@ export class KeyServiceLinux { } } - public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise { + public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise { try { const helperPath = this.getHelperPath() @@ -193,29 +193,63 @@ export class KeyServiceLinux { const targetAddr = scanRes.target_addr onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0) - return await new Promise((resolve) => { - const options = { name: 'WeFlow' } - const command = `"${helperPath}" db_hook ${pid} ${targetAddr}` + if (!this.sudo || typeof this.sudo.exec !== 'function') { + const err = 'Linux 授权组件 @vscode/sudo-prompt 未加载,请确认依赖已安装并重新启动 WeFlow' + onStatus?.(err, 2) + return { success: false, error: err } + } - this.sudo.exec(command, options, (error, stdout) => { + return await new Promise((resolve) => { + const options = { + name: 'WeFlow', + env: { + PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin` + } + } + const timeoutSec = Math.ceil((timeoutMs + 15_000) / 1000) + const command = `timeout -k 5s ${timeoutSec}s "${helperPath}" db_hook ${pid} ${targetAddr} ${timeoutMs}` + let settled = false + const finish = (result: DbKeyResult) => { + if (settled) return + settled = true + clearTimeout(watchdog) + resolve(result) + } + const watchdog = setTimeout(() => { + execAsync(`kill -CONT ${pid}`).catch(() => {}) + const err = `Hook 等待超时(${Math.round(timeoutMs / 1000)} 秒)。请确认微信登录确认已完成,或重启微信后重试。` + onStatus?.(err, 2) + finish({ success: false, error: err }) + }, timeoutMs + 30_000) + + onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0) + + this.sudo.exec(command, options, (error, stdout, stderr) => { execAsync(`kill -CONT ${pid}`).catch(() => {}) if (error) { - onStatus?.('授权失败或被取消', 2) - resolve({ success: false, error: `授权失败或被取消: ${error.message}` }) + const detail = String(stderr || '').trim() + const message = detail ? `${error.message}: ${detail}` : error.message + onStatus?.('授权失败或 Hook 执行失败', 2) + finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` }) return } try { - const hookRes = JSON.parse((stdout as string).trim()) + const output = String(stdout || '').trim() + if (!output) { + const detail = String(stderr || '').trim() + throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出') + } + const hookRes = JSON.parse(output) if (hookRes.success) { onStatus?.('密钥获取成功', 1) - resolve({ success: true, key: hookRes.key }) + finish({ success: true, key: hookRes.key }) } else { onStatus?.(hookRes.result, 2) - resolve({ success: false, error: hookRes.result }) + finish({ success: false, error: hookRes.result }) } - } catch (e) { + } catch (e: any) { onStatus?.('解析 Hook 结果失败', 2) - resolve({ success: false, error: '解析 Hook 结果失败' }) + finish({ success: false, error: e?.message || '解析 Hook 结果失败' }) } }) }) diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 7d4e4d0..e094918 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -707,7 +707,7 @@ export class KeyServiceMac { } if (code === 'HOOK_FAILED') { if (normalizedDetail.includes('HOOK_TIMEOUT')) { - return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。' + return 'Hook 已安装,但在等待时间内未触发登录流程。请退出微信账号后重新登录,或在未登录状态下直接登录微信,完成一次登录流程后重试。' } if (normalizedDetail.includes('attach_wait_timeout')) { return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。' diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index cacdcf4..8a8c888 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -1325,13 +1325,19 @@ class MessagePushService { } private getMessageDisplayContent(message: Message): string | null { + const normalizeTextContent = (value: string | null | undefined): string | null => { + const text = String(value || '') + if (!text) return null + return text.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '').trim() + } + const cleanOfficialPrefix = (value: string | null): string | null => { if (!value) return value return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value } switch (Number(message.localType || 0)) { case 1: - return cleanOfficialPrefix(message.rawContent || null) + return cleanOfficialPrefix(normalizeTextContent(message.parsedContent || message.rawContent)) case 3: return '[图片]' case 34: @@ -1347,7 +1353,7 @@ class MessagePushService { case 49: return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]') default: - return cleanOfficialPrefix(message.parsedContent || message.rawContent || null) + return cleanOfficialPrefix(normalizeTextContent(message.parsedContent || message.rawContent) || null) } } diff --git a/electron/services/nativeImageDecrypt.ts b/electron/services/nativeImageDecrypt.ts index bcaacb7..3a78137 100644 --- a/electron/services/nativeImageDecrypt.ts +++ b/electron/services/nativeImageDecrypt.ts @@ -6,10 +6,30 @@ type NativeDecryptResult = { ext: string isWxgf?: boolean is_wxgf?: boolean + version?: number + aesSize?: number + aes_size?: number + xorSize?: number + xor_size?: number + rawSize?: number + raw_size?: number + flag?: number +} + +export type NativeDatMeta = { + version?: number + aesSize?: number + aes_size?: number + xorSize?: number + xor_size?: number + rawSize?: number + raw_size?: number + flag?: number } type NativeAddon = { decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult + encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string, meta?: NativeDatMeta) => Buffer } let cachedAddon: NativeAddon | null | undefined @@ -91,7 +111,7 @@ export function decryptDatViaNative( inputPath: string, xorKey: number, aesKey?: string -): { data: Buffer; ext: string; isWxgf: boolean } | null { +): { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null { const addon = loadAddon() if (!addon) return null @@ -103,7 +123,31 @@ export function decryptDatViaNative( ? result.ext.trim().toLowerCase() : '' const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : '' - return { data: result.data, ext, isWxgf } + const meta: NativeDatMeta = { + version: result.version, + aes_size: result.aes_size ?? result.aesSize, + xor_size: result.xor_size ?? result.xorSize, + raw_size: result.raw_size ?? result.rawSize, + flag: result.flag + } + return { data: result.data, ext, isWxgf, meta } + } catch { + return null + } +} + +export function encryptDatViaNative( + inputPath: string, + xorKey: number, + aesKey?: string, + meta?: NativeDatMeta +): Buffer | null { + const addon = loadAddon() + if (!addon || typeof addon.encryptDatNative !== 'function') return null + + try { + const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta) + return Buffer.isBuffer(result) ? result : null } catch { return null } diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 6b5af11..68cb0fb 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -1340,6 +1340,8 @@ class SnsService { }, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: { shouldPause?: () => boolean shouldStop?: () => boolean + recordCreatedFile?: (filePath: string) => void + recordCreatedDir?: (dirPath: string) => void }): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> { const { outputDir, format, usernames, keyword, startTime, endTime } = options const hasExplicitMediaSelection = @@ -1361,6 +1363,18 @@ class SnsService { if (control?.shouldPause?.()) return 'paused' return null } + const ensureExportDir = (dirPath: string) => { + const existed = existsSync(dirPath) + if (!existed) { + mkdirSync(dirPath, { recursive: true }) + control?.recordCreatedDir?.(dirPath) + } + } + const recordCreatedFileBeforeWrite = (filePath: string) => { + if (!existsSync(filePath)) { + control?.recordCreatedFile?.(filePath) + } + } const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => ( state === 'stopped' ? { success: true, stopped: true, filePath: '', postCount, mediaCount } @@ -1369,9 +1383,7 @@ class SnsService { try { // 确保输出目录存在 - if (!existsSync(outputDir)) { - mkdirSync(outputDir, { recursive: true }) - } + ensureExportDir(outputDir) // 1. 分页加载全部帖子 const allPosts: SnsPost[] = [] @@ -1414,9 +1426,7 @@ class SnsService { const mediaDir = join(outputDir, 'media') if (shouldExportMedia) { - if (!existsSync(mediaDir)) { - mkdirSync(mediaDir, { recursive: true }) - } + ensureExportDir(mediaDir) // 收集所有媒体下载任务 const mediaTasks: Array<{ @@ -1485,6 +1495,7 @@ class SnsService { } else { const result = await this.fetchAndDecryptImage(task.url, task.key) if (result.success && result.data) { + recordCreatedFileBeforeWrite(filePath) await writeFile(filePath, result.data) if (task.kind === 'livephoto') { if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` @@ -1494,6 +1505,7 @@ class SnsService { mediaCount++ } else if (result.success && result.cachePath) { const cachedData = await readFile(result.cachePath) + recordCreatedFileBeforeWrite(filePath) await writeFile(filePath, cachedData) if (task.kind === 'livephoto') { if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` @@ -1531,7 +1543,7 @@ class SnsService { // 2.5 下载头像 const avatarMap = new Map() if (format === 'html') { - if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true }) + ensureExportDir(mediaDir) const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()] let avatarDone = 0 const avatarQueue = [...uniqueUsers] @@ -1548,6 +1560,7 @@ class SnsService { } else { const result = await this.fetchAndDecryptImage(post.avatarUrl!) if (result.success && result.data) { + recordCreatedFileBeforeWrite(filePath) await writeFile(filePath, result.data) avatarMap.set(post.username, `media/${fileName}`) } @@ -1602,6 +1615,7 @@ class SnsService { linkUrl: (p as any).linkUrl })) } + recordCreatedFileBeforeWrite(outputFilePath) await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') } else if (format === 'arkmejson') { outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`) @@ -1689,11 +1703,13 @@ class SnsService { }, posts } + recordCreatedFileBeforeWrite(outputFilePath) await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') } else { // HTML 格式 outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`) const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap) + recordCreatedFileBeforeWrite(outputFilePath) await writeFile(outputFilePath, html, 'utf-8') } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index af797f7..489991b 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -91,6 +91,11 @@ export class WcdbCore { private wcdbGetSnsUsernames: any = null private wcdbGetSnsExportStats: any = null private wcdbGetMessageTableColumns: any = null + private wcdbListTables: any = null + private wcdbGetTableSchema: any = null + private wcdbExportTableSnapshot: any = null + private wcdbImportTableSnapshot: any = null + private wcdbImportTableSnapshotWithSchema: any = null private wcdbGetMessageTableTimeRange: any = null private wcdbResolveImageHardlink: any = null private wcdbResolveImageHardlinkBatch: any = null @@ -1090,6 +1095,31 @@ export class WcdbCore { } catch { this.wcdbGetMessageTableColumns = null } + try { + this.wcdbListTables = this.lib.func('int32 wcdb_list_tables(int64 handle, const char* kind, const char* dbPath, _Out_ void** outJson)') + } catch { + this.wcdbListTables = null + } + try { + this.wcdbGetTableSchema = this.lib.func('int32 wcdb_get_table_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, _Out_ void** outJson)') + } catch { + this.wcdbGetTableSchema = null + } + try { + this.wcdbExportTableSnapshot = this.lib.func('int32 wcdb_export_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* outputPath, _Out_ void** outJson)') + } catch { + this.wcdbExportTableSnapshot = null + } + try { + this.wcdbImportTableSnapshot = this.lib.func('int32 wcdb_import_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, _Out_ void** outJson)') + } catch { + this.wcdbImportTableSnapshot = null + } + try { + this.wcdbImportTableSnapshotWithSchema = this.lib.func('int32 wcdb_import_table_snapshot_with_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, const char* createTableSql, _Out_ void** outJson)') + } catch { + this.wcdbImportTableSnapshotWithSchema = null + } try { this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') } catch { @@ -2902,6 +2932,96 @@ export class WcdbCore { } } + async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbListTables) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbListTables(this.handle, kind, dbPath || '', outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取表列表失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析表列表失败' } + const tables = JSON.parse(jsonStr) + return { success: true, tables: Array.isArray(tables) ? tables.map((c: any) => String(c || '')).filter(Boolean) : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetTableSchema) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetTableSchema(this.handle, kind, dbPath || '', tableName, outPtr) + const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' + const data = jsonStr ? JSON.parse(jsonStr) : {} + if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `获取表结构失败: ${result}` } + return { success: true, schema: String(data?.schema || '') } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbExportTableSnapshot) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbExportTableSnapshot(this.handle, kind, dbPath || '', tableName, outputPath, outPtr) + const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' + const data = jsonStr ? JSON.parse(jsonStr) : {} + if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导出表快照失败: ${result}` } + return { success: true, rows: Number(data?.rows || 0), columns: Number(data?.columns || 0) } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbImportTableSnapshot) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbImportTableSnapshot(this.handle, kind, dbPath || '', tableName, inputPath, outPtr) + const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' + const data = jsonStr ? JSON.parse(jsonStr) : {} + if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` } + return { + success: true, + rows: Number(data?.rows || 0), + inserted: Number(data?.inserted || 0), + ignored: Number(data?.ignored || 0), + malformed: Number(data?.malformed || 0), + columns: Number(data?.columns || 0) + } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbImportTableSnapshotWithSchema) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbImportTableSnapshotWithSchema(this.handle, kind, dbPath || '', tableName, inputPath, createTableSql || '', outPtr) + const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' + const data = jsonStr ? JSON.parse(jsonStr) : {} + if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` } + return { + success: true, + rows: Number(data?.rows || 0), + inserted: Number(data?.inserted || 0), + ignored: Number(data?.ignored || 0), + malformed: Number(data?.malformed || 0), + columns: Number(data?.columns || 0) + } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 2f1957f..e33dc64 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -92,6 +92,9 @@ export class WcdbService { this.setPaths(this.resourcesPath, this.userDataPath) } this.setLogEnabled(this.logEnabled) + if (this.monitorListener) { + this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { }) + } } catch (e) { // Failed to create worker @@ -366,6 +369,26 @@ export class WcdbService { return this.callWorker('getMessageTableColumns', { dbPath, tableName }) } + async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> { + return this.callWorker('listTables', { kind, dbPath }) + } + + async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> { + return this.callWorker('getTableSchema', { kind, dbPath, tableName }) + } + + async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> { + return this.callWorker('exportTableSnapshot', { kind, dbPath, tableName, outputPath }) + } + + async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { + return this.callWorker('importTableSnapshot', { kind, dbPath, tableName, inputPath }) + } + + async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { + return this.callWorker('importTableSnapshotWithSchema', { kind, dbPath, tableName, inputPath, createTableSql }) + } + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { return this.callWorker('getMessageTableTimeRange', { dbPath, tableName }) } diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 2992d01..d8e3ed3 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -116,6 +116,21 @@ if (parentPort) { case 'getMessageTableColumns': result = await core.getMessageTableColumns(payload.dbPath, payload.tableName) break + case 'listTables': + result = await core.listTables(payload.kind, payload.dbPath) + break + case 'getTableSchema': + result = await core.getTableSchema(payload.kind, payload.dbPath, payload.tableName) + break + case 'exportTableSnapshot': + result = await core.exportTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.outputPath) + break + case 'importTableSnapshot': + result = await core.importTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.inputPath) + break + case 'importTableSnapshotWithSchema': + result = await core.importTableSnapshotWithSchema(payload.kind, payload.dbPath, payload.tableName, payload.inputPath, payload.createTableSql) + break case 'getMessageTableTimeRange': result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName) break diff --git a/package-lock.json b/package-lock.json index 4e94508..fc40713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "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", @@ -29,7 +30,6 @@ "remark-gfm": "^4.0.1", "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" }, @@ -3050,6 +3050,12 @@ "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", @@ -9456,13 +9462,6 @@ "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", diff --git a/package.json b/package.json index 7c6f375..df9867d 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", - "sudo-prompt": "^9.2.1", + "@vscode/sudo-prompt": "^9.3.2", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, diff --git a/resources/key/linux/x64/xkey_helper_linux b/resources/key/linux/x64/xkey_helper_linux index 8deb6d4..8b3cd18 100755 Binary files a/resources/key/linux/x64/xkey_helper_linux and b/resources/key/linux/x64/xkey_helper_linux differ diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index e367305..d5feb7a 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index 88c9fd4..bc24c25 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index a0abeb1..cd1799c 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 8d02f6c..c223d45 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node index f0c1837..f61ba21 100644 Binary files a/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node and b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node differ diff --git a/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node index 02a4881..7a9a1a0 100644 Binary files a/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node and b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node differ diff --git a/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node index eebe65e..e3bdaa6 100644 Binary files a/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node and b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node differ diff --git a/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node index bea4af9..7b370ad 100644 Binary files a/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node and b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node differ diff --git a/src/App.tsx b/src/App.tsx index bb5d7f4..5834978 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import ResourcesPage from './pages/ResourcesPage' import ChatHistoryPage from './pages/ChatHistoryPage' import NotificationWindow from './pages/NotificationWindow' import AccountManagementPage from './pages/AccountManagementPage' +import BackupPage from './pages/BackupPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore' @@ -705,6 +706,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4b9a0e7..6609c20 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users } from 'lucide-react' +import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore } from 'lucide-react' import { useAppStore } from '../stores/appStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' @@ -412,6 +412,15 @@ function Sidebar({ collapsed }: SidebarProps) { )} + + + 数据库备份 + + diff --git a/src/pages/BackupPage.scss b/src/pages/BackupPage.scss new file mode 100644 index 0000000..f05e82e --- /dev/null +++ b/src/pages/BackupPage.scss @@ -0,0 +1,298 @@ +.backup-page { + height: 100%; + overflow: auto; + padding: 24px; + color: var(--text-primary); + background: var(--bg-primary); +} + +.backup-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; + + h1 { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: 0; + } + + p { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 14px; + } +} + +.backup-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.resource-options { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin: -8px 0 18px; + + label { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 36px; + padding: 8px 10px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + } + + input { + margin: 0; + } + + svg { + color: var(--primary); + } +} + +.primary-btn, +.secondary-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 9px 12px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.primary-btn { + background: var(--primary); + color: var(--on-primary); + border-color: var(--primary); +} + +.secondary-btn { + background: var(--bg-secondary); + color: var(--text-primary); + + &:not(:disabled):hover { + background: var(--bg-tertiary); + } +} + +.backup-status-band { + min-height: 88px; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 18px; + padding: 16px 0; +} + +.status-icon { + width: 42px; + height: 42px; + border-radius: 8px; + background: var(--bg-secondary); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.status-body { + min-width: 0; + flex: 1; +} + +.status-title { + font-size: 15px; + font-weight: 700; + margin-bottom: 4px; +} + +.status-detail { + color: var(--text-secondary); + font-size: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.progress-track { + margin-top: 12px; + height: 6px; + background: var(--bg-tertiary); + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--primary); + transition: width 0.2s ease; +} + +.backup-summary, +.restore-result { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.summary-item, +.restore-result > div { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + padding: 14px; + min-height: 74px; + display: flex; + flex-direction: column; + gap: 6px; + + svg { + color: var(--primary); + } + + span { + color: var(--text-secondary); + font-size: 12px; + } + + strong { + color: var(--text-primary); + font-size: 20px; + line-height: 1.1; + } +} + +.backup-detail { + border-top: 1px solid var(--border-color); + padding-top: 18px; +} + +.detail-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 18px; + } + + span { + color: var(--text-secondary); + font-size: 12px; + } +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; + + div { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + background: var(--bg-secondary); + min-width: 0; + } + + span { + display: block; + color: var(--text-secondary); + font-size: 12px; + margin-bottom: 5px; + } + + strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + } +} + +.db-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.db-row { + display: grid; + grid-template-columns: 110px 80px minmax(0, 1fr); + gap: 10px; + align-items: center; + border-bottom: 1px solid var(--border-color); + padding: 9px 0; + font-size: 13px; + + span { + color: var(--primary); + font-weight: 700; + } + + strong { + font-weight: 600; + } + + em { + color: var(--text-secondary); + font-style: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +@media (max-width: 760px) { + .backup-header { + flex-direction: column; + } + + .backup-actions { + width: 100%; + justify-content: flex-start; + } + + .backup-summary, + .restore-result, + .detail-grid { + grid-template-columns: 1fr; + } + + .db-row { + grid-template-columns: 82px 64px minmax(0, 1fr); + } +} diff --git a/src/pages/BackupPage.tsx b/src/pages/BackupPage.tsx new file mode 100644 index 0000000..2873329 --- /dev/null +++ b/src/pages/BackupPage.tsx @@ -0,0 +1,305 @@ +import { useEffect, useMemo, useState } from 'react' +import { ArchiveRestore, Database, Download, File, FileArchive, Image, Upload, Video } from 'lucide-react' +import './BackupPage.scss' + +type BackupManifest = NonNullable>['manifest']> +type BackupProgress = Parameters[0]>[0] + +function formatDate(value?: string): string { + if (!value) return '-' + try { + return new Date(value).toLocaleString() + } catch { + return value + } +} + +function summarizeManifest(manifest?: BackupManifest | null) { + if (!manifest) return { dbCount: 0, tableCount: 0, rowCount: 0, resourceCount: 0 } + let tableCount = 0 + let rowCount = 0 + for (const db of manifest.databases || []) { + tableCount += db.tables?.length || 0 + rowCount += (db.tables || []).reduce((sum, table) => sum + (table.rows || 0), 0) + } + const resourceCount = + (manifest.resources?.images?.length || 0) + + (manifest.resources?.videos?.length || 0) + + (manifest.resources?.files?.length || 0) + return { dbCount: manifest.databases?.length || 0, tableCount, rowCount, resourceCount } +} + +function BackupPage() { + const [progress, setProgress] = useState(null) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + const [selectedArchive, setSelectedArchive] = useState('') + const [manifest, setManifest] = useState(null) + const [restoreSummary, setRestoreSummary] = useState<{ inserted: number; ignored: number; skipped: number } | null>(null) + const [resourceOptions, setResourceOptions] = useState({ + includeImages: false, + includeVideos: false, + includeFiles: false + }) + + useEffect(() => { + return window.electronAPI.backup.onProgress(setProgress) + }, []) + + const summary = useMemo(() => summarizeManifest(manifest), [manifest]) + const percent = progress?.total && progress.total > 0 + ? Math.min(100, Math.round(((progress.current || 0) / progress.total) * 100)) + : (busy ? 8 : 0) + + const handleCreateBackup = async () => { + if (busy) return + setBusy(true) + setProgress(null) + setMessage('') + setRestoreSummary(null) + try { + const hasResources = resourceOptions.includeImages || resourceOptions.includeVideos || resourceOptions.includeFiles + const extension = hasResources ? 'tar' : 'tar.gz' + const defaultPath = `weflow-db-backup-${new Date().toISOString().slice(0, 10)}.${extension}` + const result = await window.electronAPI.dialog.saveFile({ + title: '保存数据库备份', + defaultPath, + filters: [{ name: 'WeFlow 数据库备份', extensions: hasResources ? ['tar'] : ['gz'] }] + }) + if (result.canceled || !result.filePath) { + setMessage('已取消') + return + } + const created = await window.electronAPI.backup.create({ + outputPath: result.filePath, + options: resourceOptions + }) + if (!created.success) { + setProgress(null) + setMessage(created.error || '备份失败') + return + } + setSelectedArchive(created.filePath || result.filePath) + setManifest(created.manifest || null) + setMessage('备份完成') + } catch (error) { + setProgress(null) + setMessage(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const handlePickArchive = async () => { + if (busy) return + setBusy(true) + setProgress(null) + setMessage('') + setRestoreSummary(null) + try { + const result = await window.electronAPI.dialog.openFile({ + title: '选择数据库备份', + properties: ['openFile'], + filters: [ + { name: 'WeFlow 数据库备份', extensions: ['tar', 'gz', 'tgz'] }, + { name: '所有文件', extensions: ['*'] } + ] + }) + if (result.canceled || !result.filePaths?.[0]) { + setMessage('已取消') + return + } + const archivePath = result.filePaths[0] + const inspected = await window.electronAPI.backup.inspect({ archivePath }) + if (!inspected.success) { + setProgress(null) + setMessage(inspected.error || '读取备份失败') + return + } + setSelectedArchive(archivePath) + setManifest(inspected.manifest || null) + setMessage('备份包已读取') + } catch (error) { + setProgress(null) + setMessage(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const handleRestore = async () => { + if (busy || !selectedArchive) return + setBusy(true) + setProgress(null) + setMessage('') + setRestoreSummary(null) + try { + const restored = await window.electronAPI.backup.restore({ archivePath: selectedArchive }) + if (!restored.success) { + setProgress(null) + setMessage(restored.error || '载入失败') + return + } + setRestoreSummary({ + inserted: restored.inserted || 0, + ignored: restored.ignored || 0, + skipped: restored.skipped || 0 + }) + setMessage('载入完成') + } catch (error) { + setProgress(null) + setMessage(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+

数据库备份

+

Snapshots 增量备份与载入

+
+
+ + + +
+
+ +
+ + + +
+ +
+
+ +
+
+
{progress?.message || message || '等待操作'}
+
{progress?.detail || selectedArchive || '未选择备份包'}
+ {busy && ( +
+
+
+ )} +
+
+ +
+
+ + 数据库 + {summary.dbCount} +
+
+ + + {summary.tableCount} +
+
+ + + {summary.rowCount.toLocaleString()} +
+
+ + 资源 + {summary.resourceCount.toLocaleString()} +
+
+ + {manifest && ( +
+
+

备份信息

+ {formatDate(manifest.createdAt)} +
+
+
+ 来源账号 + {manifest.source.wxid || '-'} +
+
+ 版本 + {manifest.appVersion || '-'} +
+
+ 资源 + + 图片 {manifest.resources?.images?.length || 0} / 视频 {manifest.resources?.videos?.length || 0} / 文件 {manifest.resources?.files?.length || 0} + +
+
+
+ {manifest.databases.map(db => ( +
+ {db.kind} + {db.tables.length} 表 + {db.relativePath} +
+ ))} +
+
+ )} + + {restoreSummary && ( +
+
+ 新增 + {restoreSummary.inserted.toLocaleString()} +
+
+ 已存在 + {restoreSummary.ignored.toLocaleString()} +
+
+ 跳过 + {restoreSummary.skipped.toLocaleString()} +
+
+ )} +
+ ) +} + +export default BackupPage diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e46649e..d0829c3 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -80,7 +80,7 @@ import { import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' -type TaskStatus = 'queued' | 'running' | 'success' | 'error' +type TaskStatus = 'queued' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' | 'sns' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' type ContentCardType = ContentType | 'sns' @@ -578,10 +578,27 @@ const formatDurationMs = (ms: number): string => { const getTaskStatusLabel = (task: ExportTask): string => { if (task.status === 'queued') return '排队中' if (task.status === 'running') return '进行中' + if (task.status === 'pause_requested') return '暂停中' + if (task.status === 'paused') return '已暂停' + if (task.status === 'cancel_requested') return '取消中' if (task.status === 'success') return '已完成' return '失败' } +const resolveExportTaskCardClass = (status: TaskStatus): 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' => { + if (status === 'pause_requested' || status === 'paused') return 'paused' + if (status === 'cancel_requested') return 'stopped' + return status +} + +const isExportTaskActiveStatus = (status: TaskStatus): boolean => ( + status === 'queued' || + status === 'running' || + status === 'pause_requested' || + status === 'paused' || + status === 'cancel_requested' +) + const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => { if (status === 'running') return 'running' if (status === 'pause_requested' || status === 'paused') return 'paused' @@ -1809,6 +1826,9 @@ interface TaskCenterModalProps { nowTick: number onClose: () => void onTogglePerfTask: (taskId: string) => void + onPauseExportTask: (taskId: string) => void + onResumeExportTask: (taskId: string) => void + onCancelExportTask: (taskId: string) => void onPauseBackgroundTask: (taskId: string) => void onResumeBackgroundTask: (taskId: string) => void onCancelBackgroundTask: (taskId: string) => void @@ -1824,6 +1844,9 @@ const TaskCenterModal = memo(function TaskCenterModal({ nowTick, onClose, onTogglePerfTask, + onPauseExportTask, + onResumeExportTask, + onCancelExportTask, onPauseBackgroundTask, onResumeBackgroundTask, onCancelBackgroundTask @@ -1954,15 +1977,31 @@ const TaskCenterModal = memo(function TaskCenterModal({ : `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}` ) : '' + const taskCardClass = resolveExportTaskCardClass(task.status) + const canShowProgress = ( + task.status === 'running' || + task.status === 'pause_requested' || + task.status === 'paused' || + task.status === 'cancel_requested' + ) + const canPause = task.status === 'running' + const canResume = task.status === 'paused' || task.status === 'pause_requested' + const canCancel = ( + task.status === 'queued' || + task.status === 'running' || + task.status === 'pause_requested' || + task.status === 'paused' || + task.status === 'cancel_requested' + ) return ( -
+
{task.title}
- {getTaskStatusLabel(task)} + {getTaskStatusLabel(task)} {new Date(task.createdAt).toLocaleString('zh-CN')}
- {task.status === 'running' && ( + {canShowProgress && ( <>
)} + {canPause && ( + + )} + {canResume && ( + + )} + {canCancel && ( + + )}