Merge pull request #847 from hicccc77/dev

Dev
This commit is contained in:
cc authored and GitHub committed 2026-04-26 11:13:55 +08:00
commit 27690ee7fa
42 files changed
+3330 -171

No files matched your search

+27 -2
View File
@@ -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"
+7 -1
View File
@@ -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"
+7 -1
View File
@@ -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"
+5 -1
View File
@@ -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
+2 -1
View File
@@ -76,4 +76,5 @@ wechat-research-site
.codex
weflow-web-offical
/Wedecrypt
/scripts/syncwcdb.py
/scripts/syncwcdb.py
/scripts/syncWedecrypt.py
+1 -1
View File
@@ -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,
+35 -1
View File
@@ -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({
+159 -33
View File
@@ -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<string, Worker>()
const activeExportTasks = new Set<string>()
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<void>((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<void>((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', () => {
}
})
+20 -2
View File
@@ -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) =>
+1084
View File
@@ -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<T>(task: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | null = null
try {
return await Promise.race([
task,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
})
])
} finally {
if (timer) clearTimeout(timer)
}
}
function delay(ms = 0): Promise<void> {
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<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>
): Promise<void> {
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<string> {
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<string[]> {
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<string[]> {
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<void> {
mkdirSync(dirname(outputPath), { recursive: true })
try {
await link(sourcePath, outputPath)
} catch {
await copyFile(sourcePath, outputPath)
}
}
private async writeTarEntryToFile(entry: any, outputPath: string): Promise<void> {
mkdirSync(dirname(outputPath), { recursive: true })
await new Promise<void>((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<string[]> {
const attachRoot = join(accountDir, 'msg', 'attach')
const result: string[] = []
if (!existsSync(attachRoot)) return result
const scanImgDir = async (imgDir: string): Promise<void> => {
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<void> => {
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<Array<Omit<BackupDbEntry, 'tables'>>> {
const result: Array<Omit<BackupDbEntry, 'tables'>> = []
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<void> {
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<void> {
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<BackupDbEntry, 'tables'>; 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<string>()
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<string, BackupResourceEntry>()
for (const image of manifest.resources?.images || []) {
const path = normalizeArchivePath(image.archivePath)
if (path) imageByPath.set(path, image)
}
const plainByPath = new Map<string, BackupResourceEntry>()
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<void>[] = []
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()
+209 -6
View File
@@ -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, any>): 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<string, any>): 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<Map<string, { displayName?: string }>> {
const targets = Array.from(new Set((usernames || []).map((value) => String(value || '').trim()).filter(Boolean)))
const map = new Map<string, { displayName?: string }>()
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<string, any>[]) {
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<boolean> {
try {
const tableStatsResult = await wcdbService.getMessageTableStats(sessionId)
if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) return false
return tableStatsResult.tables.some((row: Record<string, any>) => {
const tableName = String(row.table_name || row.tableName || '').trim()
return tableName.length > 0
})
} catch {
return false
}
}
private async buildAntiRevokeSessionsFromRows(rows: Record<string, any>[]): Promise<ChatSession[]> {
if (rows.length > 0 && (rows[0]._error || rows[0]._info)) return []
const candidateRows: Array<{ username: string; row: Record<string, any> }> = []
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<void> {
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,
+2
View File
@@ -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: '',
+151 -48
View File
@@ -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<string, Promise<string | null>>()
private mediaFileCacheReadyDirs = new Set<string>()
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<string>): Promise<void> {
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<void> {
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<string>
control?: ExportTaskControl
}
): Promise<MediaExportItem | null> {
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<string>
dirCache?: Set<string>,
control?: ExportTaskControl
): Promise<MediaExportItem | null> {
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<string | null> => {
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<string>
dirCache?: Set<string>,
control?: ExportTaskControl
): Promise<MediaExportItem | null> {
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<string>
dirCache?: Set<string>,
control?: ExportTaskControl
): Promise<MediaExportItem | null> {
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<string>,
includePoster = false
includePoster = false,
control?: ExportTaskControl
): Promise<MediaExportItem | null> {
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<string>
dirCache?: Set<string>,
control?: ExportTaskControl
): Promise<MediaExportItem | null> {
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<Map<string, string>> {
const result = new Map<string, string>()
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<void> => {
await new Promise<void>((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<void> => {
await new Promise<void>((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<string, string>()
@@ -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<string>()
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,
@@ -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<string>
dirs: Set<string>
}
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<string, ExportTaskControlRecord>()
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<string>(),
dirs: new Set<string>()
},
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<ExportTaskCleanupResult> {
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()
+50 -8
View File
@@ -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<void> {
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<string, any> {
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|<br\s*\/?>)\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
}
}
+47 -13
View File
@@ -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<DbKeyResult> {
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise<DbKeyResult> {
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 结果失败' })
}
})
})
+1 -1
View File
@@ -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 阶段。请确认微信处于可交互状态并重试。'
+8 -2
View File
@@ -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|<br\s*\/?>)\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)
}
}
+46 -2
View File
@@ -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
}
+23 -7
View File
@@ -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<string, string>()
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')
}
+120
View File
@@ -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: '接口未就绪' }
+23
View File
@@ -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 })
}
+15
View File
@@ -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
+7 -8
View File
@@ -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",
+1 -1
View File
@@ -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"
},
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -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() {
<Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/backup" element={<BackupPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes>
+10 -1
View File
@@ -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) {
)}
</NavLink>
<NavLink
to="/backup"
className={`nav-item ${isActive('/backup') ? 'active' : ''}`}
title={collapsed ? '数据库备份' : undefined}
>
<span className="nav-icon"><ArchiveRestore size={20} /></span>
<span className="nav-label">数据库备份</span>
</NavLink>
</nav>
+298
View File
@@ -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);
}
}
+305
View File
@@ -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<Awaited<ReturnType<typeof window.electronAPI.backup.inspect>>['manifest']>
type BackupProgress = Parameters<Parameters<typeof window.electronAPI.backup.onProgress>[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<BackupProgress | null>(null)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [selectedArchive, setSelectedArchive] = useState('')
const [manifest, setManifest] = useState<BackupManifest | null>(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 (
<div className="backup-page">
<div className="backup-header">
<div>
<h1>数据库备份</h1>
<p>Snapshots 增量备份与载入</p>
</div>
<div className="backup-actions">
<button className="primary-btn" onClick={handleCreateBackup} disabled={busy}>
<Download size={16} />
<span>创建备份</span>
</button>
<button className="secondary-btn" onClick={handlePickArchive} disabled={busy}>
<FileArchive size={16} />
<span>选择备份</span>
</button>
<button className="secondary-btn" onClick={handleRestore} disabled={busy || !selectedArchive}>
<Upload size={16} />
<span>载入</span>
</button>
</div>
</div>
<section className="resource-options" aria-label="资源备份选项">
<label>
<input
type="checkbox"
checked={resourceOptions.includeImages}
disabled={busy}
onChange={(event) => setResourceOptions(prev => ({ ...prev, includeImages: event.target.checked }))}
/>
<Image size={16} />
<span>图片</span>
</label>
<label>
<input
type="checkbox"
checked={resourceOptions.includeVideos}
disabled={busy}
onChange={(event) => setResourceOptions(prev => ({ ...prev, includeVideos: event.target.checked }))}
/>
<Video size={16} />
<span>视频</span>
</label>
<label>
<input
type="checkbox"
checked={resourceOptions.includeFiles}
disabled={busy}
onChange={(event) => setResourceOptions(prev => ({ ...prev, includeFiles: event.target.checked }))}
/>
<File size={16} />
<span>文件</span>
</label>
</section>
<div className="backup-status-band">
<div className="status-icon">
<ArchiveRestore size={22} />
</div>
<div className="status-body">
<div className="status-title">{progress?.message || message || '等待操作'}</div>
<div className="status-detail">{progress?.detail || selectedArchive || '未选择备份包'}</div>
{busy && (
<div className="progress-track">
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
)}
</div>
</div>
<section className="backup-summary">
<div className="summary-item">
<Database size={18} />
<span>数据库</span>
<strong>{summary.dbCount}</strong>
</div>
<div className="summary-item">
<Database size={18} />
<span>表</span>
<strong>{summary.tableCount}</strong>
</div>
<div className="summary-item">
<Database size={18} />
<span>行</span>
<strong>{summary.rowCount.toLocaleString()}</strong>
</div>
<div className="summary-item">
<FileArchive size={18} />
<span>资源</span>
<strong>{summary.resourceCount.toLocaleString()}</strong>
</div>
</section>
{manifest && (
<section className="backup-detail">
<div className="detail-heading">
<h2>备份信息</h2>
<span>{formatDate(manifest.createdAt)}</span>
</div>
<div className="detail-grid">
<div>
<span>来源账号</span>
<strong>{manifest.source.wxid || '-'}</strong>
</div>
<div>
<span>版本</span>
<strong>{manifest.appVersion || '-'}</strong>
</div>
<div>
<span>资源</span>
<strong>
图片 {manifest.resources?.images?.length || 0} / 视频 {manifest.resources?.videos?.length || 0} / 文件 {manifest.resources?.files?.length || 0}
</strong>
</div>
</div>
<div className="db-list">
{manifest.databases.map(db => (
<div className="db-row" key={db.id}>
<span>{db.kind}</span>
<strong>{db.tables.length} 表</strong>
<em>{db.relativePath}</em>
</div>
))}
</div>
</section>
)}
{restoreSummary && (
<section className="restore-result">
<div>
<span>新增</span>
<strong>{restoreSummary.inserted.toLocaleString()}</strong>
</div>
<div>
<span>已存在</span>
<strong>{restoreSummary.ignored.toLocaleString()}</strong>
</div>
<div>
<span>跳过</span>
<strong>{restoreSummary.skipped.toLocaleString()}</strong>
</div>
</section>
)}
</div>
)
}
export default BackupPage
+253 -18
View File
@@ -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 (
<div key={task.id} className={`task-card ${task.status}`}>
<div key={task.id} className={`task-card ${taskCardClass}`}>
<div className="task-main">
<div className="task-title">{task.title}</div>
<div className="task-meta">
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
<span className={`task-status ${taskCardClass}`}>{getTaskStatusLabel(task)}</span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
{task.status === 'running' && (
{canShowProgress && (
<>
<div className="task-progress-bar">
<div
@@ -2050,6 +2089,34 @@ const TaskCenterModal = memo(function TaskCenterModal({
{isPerfExpanded ? '收起详情' : '性能详情'}
</button>
)}
{canPause && (
<button
className="task-action-btn"
type="button"
onClick={() => onPauseExportTask(task.id)}
>
<Pause size={14} /> 暂停
</button>
)}
{canResume && (
<button
className="task-action-btn primary"
type="button"
onClick={() => onResumeExportTask(task.id)}
>
<Play size={14} /> 继续
</button>
)}
{canCancel && (
<button
className="task-action-btn danger"
type="button"
onClick={() => onCancelExportTask(task.id)}
disabled={task.status === 'cancel_requested'}
>
<Square size={14} /> {task.status === 'cancel_requested' ? '取消中' : '取消'}
</button>
)}
<button
className="task-action-btn"
onClick={() => {
@@ -5586,7 +5653,7 @@ function ExportPage() {
const now = Date.now()
const currentSessionId = String(payload.currentSessionId || '').trim()
updateTask(next.id, task => {
if (task.status !== 'running') return task
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') return task
const performance = applyProgressToTaskPerformance(task, payload, now)
const settledSessionIds = task.settledSessionIds || []
const nextSettledSessionIds = (
@@ -5740,7 +5807,8 @@ function ExportPage() {
exportLivePhotos: snsOptions.exportLivePhotos,
exportVideos: snsOptions.exportVideos,
startTime: snsOptions.startTime,
endTime: snsOptions.endTime
endTime: snsOptions.endTime,
taskId: next.id
})
if (!result.success) {
@@ -5751,6 +5819,19 @@ function ExportPage() {
error: result.error || '朋友圈导出失败',
performance: finalizeTaskPerformance(task, Date.now())
}))
} else if (result.stopped) {
setTasks(prev => prev.filter(task => task.id !== next.id))
} else if (result.paused) {
updateTask(next.id, task => ({
...task,
status: 'paused',
progress: {
...task.progress,
phaseLabel: '已暂停,可继续或取消',
current: Math.max(task.progress.current, result.postCount || 0),
total: Math.max(task.progress.total, result.postCount || 0)
}
}))
} else {
const doneAt = Date.now()
const exportedPosts = Math.max(0, result.postCount || 0)
@@ -5782,7 +5863,8 @@ function ExportPage() {
const result = await window.electronAPI.export.exportSessions(
next.payload.sessionIds,
next.payload.outputDir,
next.payload.options
next.payload.options,
{ taskId: next.id }
)
if (!result.success) {
@@ -5793,6 +5875,33 @@ function ExportPage() {
error: result.error || '导出失败',
performance: finalizeTaskPerformance(task, Date.now())
}))
} else if (result.stopped) {
setTasks(prev => prev.filter(task => task.id !== next.id))
} else if (result.paused) {
const pendingSessionIds = Array.isArray(result.pendingSessionIds)
? result.pendingSessionIds
: []
updateTask(next.id, task => ({
...task,
status: 'paused',
payload: {
...task.payload,
sessionIds: pendingSessionIds.length > 0 ? pendingSessionIds : task.payload.sessionIds
},
settledSessionIds: Array.isArray(result.successSessionIds)
? Array.from(new Set([...(task.settledSessionIds || []), ...result.successSessionIds]))
: task.settledSessionIds,
sessionOutputPaths: {
...(task.sessionOutputPaths || {}),
...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object')
? result.sessionOutputPaths
: {})
},
progress: {
...task.progress,
phaseLabel: '已暂停,可继续或取消'
}
}))
} else {
const doneAt = Date.now()
const contentTypes = next.payload.contentType
@@ -5913,7 +6022,13 @@ function ExportPage() {
}
const hasConflict = tasksRef.current.some((item) => {
if (item.status !== 'running' && item.status !== 'queued') return false
if (
item.status !== 'running' &&
item.status !== 'queued' &&
item.status !== 'pause_requested' &&
item.status !== 'paused' &&
item.status !== 'cancel_requested'
) return false
return item.payload.automationTaskId === task.id
})
if (hasConflict) {
@@ -6200,7 +6315,7 @@ function ExportPage() {
const runningSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'running') continue
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') continue
const settled = new Set(task.settledSessionIds || [])
for (const id of task.payload.sessionIds) {
if (settled.has(id)) continue
@@ -6213,7 +6328,7 @@ function ExportPage() {
const queuedSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'queued') continue
if (task.status !== 'queued' && task.status !== 'paused') continue
for (const id of task.payload.sessionIds) {
set.add(id)
}
@@ -6224,7 +6339,7 @@ function ExportPage() {
const inProgressSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'running' && task.status !== 'queued') continue
if (!isExportTaskActiveStatus(task.status)) continue
for (const id of task.payload.sessionIds) {
set.add(id)
}
@@ -6232,7 +6347,7 @@ function ExportPage() {
return Array.from(set).sort()
}, [tasks])
const activeTaskCount = useMemo(
() => tasks.filter(task => task.status === 'running' || task.status === 'queued').length,
() => tasks.filter(task => isExportTaskActiveStatus(task.status)).length,
[tasks]
)
@@ -6247,7 +6362,7 @@ function ExportPage() {
if (previousStatus === task.status) continue
const now = Date.now()
if (task.status === 'running') {
if (task.status === 'running' || task.status === 'pause_requested' || task.status === 'paused' || task.status === 'cancel_requested') {
patchAutomationTask(automationTaskId, (current) => ({
...current,
updatedAt: now,
@@ -6338,7 +6453,13 @@ function ExportPage() {
if (task.runState?.lastScheduleKey === scheduleKey) continue
const hasConflict = tasksRef.current.some((item) => {
if (item.status !== 'running' && item.status !== 'queued') return false
if (
item.status !== 'running' &&
item.status !== 'queued' &&
item.status !== 'pause_requested' &&
item.status !== 'paused' &&
item.status !== 'cancel_requested'
) return false
return item.payload.automationTaskId === task.id
})
if (hasConflict) {
@@ -6448,7 +6569,7 @@ function ExportPage() {
const runningCardTypes = useMemo(() => {
const set = new Set<ContentCardType>()
for (const task of tasks) {
if (task.status !== 'running') continue
if (!isExportTaskActiveStatus(task.status)) continue
if (task.payload.scope === 'sns') {
set.add('sns')
continue
@@ -7891,7 +8012,12 @@ function ExportPage() {
)
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskRunningCount = tasks.filter(task => (
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)).length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const chatBackgroundTasks = useMemo(() => (
backgroundTasks.filter(task => task.sourcePage === 'chat')
@@ -8105,6 +8231,112 @@ function ExportPage() {
const toggleTaskPerfDetail = useCallback((taskId: string) => {
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
}, [])
const handlePauseExportTask = useCallback((taskId: string) => {
const task = tasksRef.current.find(item => item.id === taskId)
if (!task || task.status !== 'running') return
updateTask(taskId, current => ({
...current,
status: 'pause_requested',
progress: {
...current.progress,
phaseLabel: current.progress.phaseLabel || '暂停请求已发送'
}
}))
window.electronAPI.export.pauseTask(taskId).then(result => {
if (result.success) return
updateTask(taskId, current => ({
...current,
status: current.status === 'pause_requested' ? 'running' : current.status,
error: result.error || '暂停请求失败'
}))
}).catch(error => {
updateTask(taskId, current => ({
...current,
status: current.status === 'pause_requested' ? 'running' : current.status,
error: String(error)
}))
})
}, [updateTask])
const handleResumeExportTask = useCallback((taskId: string) => {
const task = tasksRef.current.find(item => item.id === taskId)
if (!task || (task.status !== 'paused' && task.status !== 'pause_requested')) return
window.electronAPI.export.resumeTask(taskId).then(result => {
const doneAt = Date.now()
if (!result.success) {
updateTask(taskId, current => ({
...current,
status: 'error',
finishedAt: doneAt,
error: result.error || '继续任务失败',
performance: finalizeTaskPerformance(current, doneAt)
}))
return
}
updateTask(taskId, current => ({
...current,
status: current.status === 'pause_requested' ? 'running' : 'queued',
finishedAt: undefined,
error: undefined,
progress: {
...current.progress,
phaseLabel: current.status === 'pause_requested' ? '继续中' : '等待继续'
}
}))
}).catch(error => {
const doneAt = Date.now()
updateTask(taskId, current => ({
...current,
status: 'error',
finishedAt: doneAt,
error: String(error),
performance: finalizeTaskPerformance(current, doneAt)
}))
})
}, [updateTask])
const handleCancelExportTask = useCallback((taskId: string) => {
const task = tasksRef.current.find(item => item.id === taskId)
if (!task) return
if (task.status === 'queued') {
setTasks(prev => prev.filter(item => item.id !== taskId))
return
}
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'paused' && task.status !== 'cancel_requested') {
return
}
updateTask(taskId, current => ({
...current,
status: 'cancel_requested',
progress: {
...current.progress,
phaseLabel: '取消请求已发送,正在安全停止'
}
}))
window.electronAPI.export.cancelTask(taskId).then(result => {
if (result.success && task.status === 'paused') {
setTasks(prev => prev.filter(item => item.id !== taskId))
return
}
if (!result.success) {
const doneAt = Date.now()
updateTask(taskId, current => ({
...current,
status: 'error',
finishedAt: doneAt,
error: result.error || '取消任务失败',
performance: finalizeTaskPerformance(current, doneAt)
}))
}
}).catch(error => {
const doneAt = Date.now()
updateTask(taskId, current => ({
...current,
status: 'error',
finishedAt: doneAt,
error: String(error),
performance: finalizeTaskPerformance(current, doneAt)
}))
})
}, [updateTask])
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
const now = Date.now()
@@ -8564,6 +8796,9 @@ function ExportPage() {
nowTick={nowTick}
onClose={closeTaskCenter}
onTogglePerfTask={toggleTaskPerfDetail}
onPauseExportTask={handlePauseExportTask}
onResumeExportTask={handleResumeExportTask}
onCancelExportTask={handleCancelExportTask}
onPauseBackgroundTask={handlePauseBackgroundTask}
onResumeBackgroundTask={handleResumeBackgroundTask}
onCancelBackgroundTask={handleCancelBackgroundTask}
@@ -8622,12 +8857,12 @@ function ExportPage() {
<div className="automation-task-list">
{sortedAutomationTasks.map((task) => {
const linkedQueueTask = tasks.find((item) => (
(item.status === 'running' || item.status === 'queued') &&
isExportTaskActiveStatus(item.status) &&
item.payload.automationTaskId === task.id
))
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
? 'running'
: linkedQueueTask?.status === 'queued'
: linkedQueueTask && isExportTaskActiveStatus(linkedQueueTask.status)
? 'queued'
: null
return (
+78 -10
View File
@@ -6,7 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
import type { ContactInfo } from '../types/models'
import type { ChatSession, ContactInfo } from '../types/models'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
@@ -195,6 +195,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [launchAtStartup, setLaunchAtStartup] = useState(false)
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
const [silentStartup, setSilentStartup] = useState(false)
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
@@ -222,6 +223,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false)
const [isUpdatingSilentStartup, setIsUpdatingSilentStartup] = useState(false)
const [appVersion, setAppVersion] = useState('')
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const [showDecryptKey, setShowDecryptKey] = useState(false)
@@ -263,6 +265,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<SessionFilterTypeValue>('all')
const [messagePushContactOptions, setMessagePushContactOptions] = useState<ContactInfo[]>([])
const [antiRevokeSessions, setAntiRevokeSessions] = useState<ChatSession[]>([])
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
@@ -445,6 +448,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedMessagePushFilterList = await configService.getMessagePushFilterList()
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
const savedSilentStartup = await configService.getSilentStartup()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout()
const savedUpdateChannel = await configService.getUpdateChannel()
@@ -502,6 +506,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
setSilentStartup(savedSilentStartup)
setWindowCloseBehavior(savedWindowCloseBehavior)
setQuoteLayout(savedQuoteLayout)
if (savedUpdateChannel) {
@@ -615,6 +620,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
}
const handleSilentStartupChange = async (enabled: boolean) => {
if (isUpdatingSilentStartup) return
try {
setIsUpdatingSilentStartup(true)
await configService.setSilentStartup(enabled)
setSilentStartup(enabled)
showMessage(enabled ? '已开启静默启动' : '已关闭静默启动', true)
} catch (e: any) {
showMessage(`设置静默启动失败: ${e?.message || String(e)}`, false)
} finally {
setIsUpdatingSilentStartup(false)
}
}
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
try {
const result = await window.electronAPI.whisper?.getModelStatus()
@@ -752,10 +772,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
const getCurrentAntiRevokeSessionIds = (): string[] =>
normalizeSessionIds(chatSessions.map((session) => session.username))
normalizeSessionIds(antiRevokeSessions.map((session) => session.username))
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
const current = getCurrentAntiRevokeSessionIds()
const ensureChatSessionsLoaded = async (): Promise<string[]> => {
const current = normalizeSessionIds(chatSessions.map((session) => session.username))
if (current.length > 0) return current
const sessionsResult = await window.electronAPI.chat.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
@@ -765,6 +785,27 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
}
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
const current = getCurrentAntiRevokeSessionIds()
if (current.length > 0) return current
const sessionsResult = await window.electronAPI.chat.getAntiRevokeSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
throw new Error(sessionsResult.error || '加载会话失败')
}
const nextSessions = sessionsResult.sessions
const nextIds = normalizeSessionIds(nextSessions.map((session) => session.username))
setAntiRevokeSessions(nextSessions)
setAntiRevokeSelectedIds((prev) => {
const allowed = new Set(nextIds)
return new Set(Array.from(prev).filter((sessionId) => allowed.has(sessionId)))
})
setAntiRevokeStatusMap((prev) => {
const allowed = new Set(nextIds)
return Object.fromEntries(Object.entries(prev).filter(([sessionId]) => allowed.has(sessionId)))
})
return nextIds
}
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
@@ -976,11 +1017,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
let canceled = false
;(async () => {
try {
// 两个 Tab 都需要会话列表;antiRevoke 还需要额外检查防撤回状态
const sessionIds = await ensureAntiRevokeSessionsLoaded()
if (canceled) return
if (activeTab === 'antiRevoke') {
await handleRefreshAntiRevokeStatus(sessionIds)
await ensureAntiRevokeSessionsLoaded()
} else {
await ensureChatSessionsLoaded()
}
} catch (e: any) {
if (!canceled) {
@@ -1684,6 +1724,35 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="divider" />
<div className="form-group">
<label>静默启动</label>
<span className="form-hint">
开启后,无论手动启动还是开机自启动,都会先驻留到系统托盘,不主动显示主窗口。
</span>
<div className="log-toggle-line">
<span className="log-status">
{isUpdatingSilentStartup
? '保存中...'
: (silentStartup ? '已开启' : '已关闭')}
</span>
<label className="switch" htmlFor="silent-startup-toggle">
<input
id="silent-startup-toggle"
className="switch-input"
type="checkbox"
checked={silentStartup}
disabled={isUpdatingSilentStartup}
onChange={(e) => {
void handleSilentStartupChange(e.target.checked)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
<div className="form-group">
<label>关闭主窗口时</label>
<span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span>
@@ -1982,7 +2051,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
const renderAntiRevokeTab = () => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((session) => {
if (!keyword) return true
@@ -4761,4 +4830,3 @@ export default SettingsPage
+12
View File
@@ -15,6 +15,7 @@ export const CONFIG_KEYS = {
WINDOW_BOUNDS: 'windowBounds',
CACHE_PATH: 'cachePath',
LAUNCH_AT_STARTUP: 'launchAtStartup',
SILENT_STARTUP: 'silentStartup',
EXPORT_PATH: 'exportPath',
AGREEMENT_ACCEPTED: 'agreementAccepted',
@@ -321,6 +322,17 @@ export async function setLaunchAtStartup(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
}
// 获取静默启动偏好
export async function getSilentStartup(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.SILENT_STARTUP)
return value === true
}
// 设置静默启动偏好
export async function setSilentStartup(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.SILENT_STARTUP, enabled)
}
// 获取 LLM 模型路径
export async function getLlmModelPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
+112 -3
View File
@@ -21,6 +21,88 @@ export interface SocialSaveWeiboCookieResult {
error?: string
}
export interface BackupProgress {
phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
message: string
current?: number
total?: number
detail?: string
}
export interface BackupOptions {
includeImages?: boolean
includeVideos?: boolean
includeFiles?: boolean
}
export interface BackupImageDatMeta {
version?: number
aesSize?: number
aes_size?: number
xorSize?: number
xor_size?: number
rawSize?: number
raw_size?: number
flag?: number
}
export interface BackupManifest {
version: 1
type: 'weflow-db-snapshots'
createdAt: string
appVersion: string
source: {
wxid: string
dbRoot: string
}
options?: BackupOptions
databases: Array<{
id: string
kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
dbPath: string
relativePath: string
tables: Array<{
name: string
snapshotPath: string
rows: number
columns: number
schemaSql?: string
}>
}>
resources?: {
images?: Array<{
kind: 'image' | 'video' | 'file'
id: string
md5?: string
sessionId?: string
createTime?: number
sourceFileName?: string
archivePath: string
targetRelativePath: string
ext?: string
size?: number
datMeta?: BackupImageDatMeta
}>
videos?: Array<{
kind: 'image' | 'video' | 'file'
id: string
md5?: string
sourceFileName?: string
archivePath: string
targetRelativePath: string
size?: number
}>
files?: Array<{
kind: 'image' | 'video' | 'file'
id: string
sourceFileName?: string
archivePath: string
targetRelativePath: string
size?: number
}>
}
}
export interface ElectronAPI {
window: {
minimize: () => void
@@ -158,6 +240,27 @@ export interface ElectronAPI {
close: () => Promise<boolean>
}
backup: {
create: (payload: { outputPath: string; options?: BackupOptions }) => Promise<{
success: boolean
filePath?: string
manifest?: BackupManifest
error?: string
}>
inspect: (payload: { archivePath: string }) => Promise<{
success: boolean
manifest?: BackupManifest
error?: string
}>
restore: (payload: { archivePath: string }) => Promise<{
success: boolean
inserted?: number
ignored?: number
skipped?: number
error?: string
}>
onProgress: (callback: (progress: BackupProgress) => void) => () => void
}
key: {
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }>
@@ -168,6 +271,7 @@ export interface ElectronAPI {
chat: {
connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
getAntiRevokeSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
getSessionStatuses: (usernames: string[]) => Promise<{
success: boolean
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
@@ -988,16 +1092,21 @@ export interface ElectronAPI {
estimatedSeconds: number
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
}>
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => Promise<{
success: boolean
successCount?: number
failCount?: number
paused?: boolean
stopped?: boolean
pendingSessionIds?: string[]
successSessionIds?: string[]
failedSessionIds?: string[]
sessionOutputPaths?: Record<string, string>
error?: string
}>
pauseTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
resumeTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
cancelTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
success: boolean
error?: string
@@ -1070,7 +1179,8 @@ export interface ElectronAPI {
exportVideos?: boolean
startTime?: number
endTime?: number
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
taskId?: string
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }>
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
@@ -1220,4 +1330,3 @@ declare global {
}
export { }