mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-26 15:09:15 +00:00
Compare commits
1 Commits
dev
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d64efdddf |
29
.github/scripts/release-utils.sh
vendored
29
.github/scripts/release-utils.sh
vendored
@@ -58,26 +58,12 @@ 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"
|
||||
@@ -85,7 +71,6 @@ 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
|
||||
}
|
||||
@@ -102,10 +87,9 @@ 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 || 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)"
|
||||
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
|
||||
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -116,19 +100,10 @@ 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"
|
||||
|
||||
2
.github/workflows/dev-daily-fixed.yml
vendored
2
.github/workflows/dev-daily-fixed.yml
vendored
@@ -386,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
|
||||
print_release_state "$REPO" "$TAG"
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
|
||||
2
.github/workflows/preview-nightly-main.yml
vendored
2
.github/workflows/preview-nightly-main.yml
vendored
@@ -429,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
|
||||
print_release_state "$REPO" "$TAG"
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||
|
||||
<p align="center">
|
||||
<img src="app.jpg" alt="WeFlow 应用预览" width="90%">
|
||||
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<!-- 第一行修复样式 -->
|
||||
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
|
||||
<br><br>
|
||||
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
|
||||
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
|
||||
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
|
||||
</p>
|
||||
|
||||
@@ -20,10 +20,9 @@
|
||||
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
|
||||
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
|
||||
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
|
||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
|
||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是可以正常交互的状态。
|
||||
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
|
||||
6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
|
||||
7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||
6. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||
|
||||
### 常见报错与应对方法
|
||||
|
||||
|
||||
@@ -1,132 +1,19 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import type { ExportOptions } from './services/exportService'
|
||||
|
||||
interface ExportWorkerConfig {
|
||||
mode?: 'sessions' | 'single' | 'contacts'
|
||||
sessionIds?: string[]
|
||||
sessionId?: string
|
||||
outputDir?: string
|
||||
outputPath?: string
|
||||
options?: any
|
||||
taskId?: string
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
dbPath?: string
|
||||
decryptKey?: string
|
||||
myWxid?: string
|
||||
imageXorKey?: unknown
|
||||
imageAesKey?: string
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
}
|
||||
|
||||
const config = workerData as ExportWorkerConfig
|
||||
const controlState = {
|
||||
pauseRequested: false,
|
||||
stopRequested: false
|
||||
}
|
||||
|
||||
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
|
||||
const CREATED_PATH_BATCH_LIMIT = 256
|
||||
const PROGRESS_POST_INTERVAL_MS = 180
|
||||
let queuedCreatedFiles: string[] = []
|
||||
let queuedCreatedDirs: string[] = []
|
||||
let createdPathFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingProgress: any = null
|
||||
let progressPostTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastProgressPostedAt = 0
|
||||
|
||||
function flushCreatedPaths() {
|
||||
if (createdPathFlushTimer) {
|
||||
clearTimeout(createdPathFlushTimer)
|
||||
createdPathFlushTimer = null
|
||||
}
|
||||
const filePaths = queuedCreatedFiles
|
||||
const dirPaths = queuedCreatedDirs
|
||||
queuedCreatedFiles = []
|
||||
queuedCreatedDirs = []
|
||||
if (!parentPort) return
|
||||
if (filePaths.length > 0) {
|
||||
parentPort.postMessage({ type: 'export:createdFiles', filePaths })
|
||||
}
|
||||
if (dirPaths.length > 0) {
|
||||
parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCreatedPathFlush() {
|
||||
if (createdPathFlushTimer) return
|
||||
createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function queueCreatedFile(filePath: string) {
|
||||
const normalized = String(filePath || '').trim()
|
||||
if (!normalized) return
|
||||
queuedCreatedFiles.push(normalized)
|
||||
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
|
||||
flushCreatedPaths()
|
||||
} else {
|
||||
scheduleCreatedPathFlush()
|
||||
}
|
||||
}
|
||||
|
||||
function queueCreatedDir(dirPath: string) {
|
||||
const normalized = String(dirPath || '').trim()
|
||||
if (!normalized) return
|
||||
queuedCreatedDirs.push(normalized)
|
||||
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
|
||||
flushCreatedPaths()
|
||||
} else {
|
||||
scheduleCreatedPathFlush()
|
||||
}
|
||||
}
|
||||
|
||||
function flushProgress() {
|
||||
if (!pendingProgress) return
|
||||
if (progressPostTimer) {
|
||||
clearTimeout(progressPostTimer)
|
||||
progressPostTimer = null
|
||||
}
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: pendingProgress
|
||||
})
|
||||
pendingProgress = null
|
||||
lastProgressPostedAt = Date.now()
|
||||
}
|
||||
|
||||
function queueProgress(progress: any) {
|
||||
pendingProgress = progress
|
||||
if (progress?.phase === 'complete') {
|
||||
flushProgress()
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastProgressPostedAt
|
||||
if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
|
||||
flushProgress()
|
||||
return
|
||||
}
|
||||
|
||||
if (progressPostTimer) return
|
||||
progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -148,49 +35,20 @@ async function run() {
|
||||
exportService.setRuntimeConfig({
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid,
|
||||
imageXorKey: config.imageXorKey,
|
||||
imageAesKey: config.imageAesKey
|
||||
myWxid: config.myWxid
|
||||
})
|
||||
|
||||
const onProgress = (progress: any) => queueProgress(progress)
|
||||
|
||||
const taskControl = config.taskId
|
||||
? {
|
||||
shouldPause: () => controlState.pauseRequested,
|
||||
shouldStop: () => controlState.stopRequested,
|
||||
recordCreatedFile: queueCreatedFile,
|
||||
recordCreatedDir: queueCreatedDir
|
||||
}
|
||||
: undefined
|
||||
|
||||
let result: any
|
||||
if (config.mode === 'contacts') {
|
||||
const { contactExportService } = await import('./services/contactExportService')
|
||||
result = await contactExportService.exportContacts(
|
||||
String(config.outputDir || ''),
|
||||
config.options || {}
|
||||
)
|
||||
} else if (config.mode === 'single') {
|
||||
result = await exportService.exportSessionToChatLab(
|
||||
String(config.sessionId || '').trim(),
|
||||
String(config.outputPath || '').trim(),
|
||||
config.options || { format: 'chatlab' },
|
||||
onProgress,
|
||||
taskControl
|
||||
)
|
||||
} else {
|
||||
result = await exportService.exportSessions(
|
||||
const result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
onProgress,
|
||||
taskControl
|
||||
)
|
||||
(progress) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: progress
|
||||
})
|
||||
}
|
||||
|
||||
flushProgress()
|
||||
flushCreatedPaths()
|
||||
)
|
||||
|
||||
parentPort?.postMessage({
|
||||
type: 'export:result',
|
||||
@@ -199,8 +57,6 @@ async function run() {
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
flushProgress()
|
||||
flushCreatedPaths()
|
||||
parentPort?.postMessage({
|
||||
type: 'export:error',
|
||||
error: String(error)
|
||||
|
||||
305
electron/main.ts
305
electron/main.ts
@@ -16,13 +16,13 @@ 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'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService, isVideoUrl } from './services/snsService'
|
||||
import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
||||
import { cloudControlService } from './services/cloudControlService'
|
||||
@@ -64,42 +64,6 @@ 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
|
||||
@@ -784,10 +748,6 @@ 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
|
||||
@@ -2277,10 +2237,6 @@ 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)
|
||||
})
|
||||
@@ -2672,25 +2628,16 @@ 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)
|
||||
|
||||
try {
|
||||
const result = await snsService.exportTimeline(
|
||||
return 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 () => {
|
||||
@@ -3013,40 +2960,7 @@ function registerIpcHandlers() {
|
||||
return exportService.getExportStats(sessionIds, options)
|
||||
})
|
||||
|
||||
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)
|
||||
if (taskId) exportTaskControlService.createControl(taskId, outputDir)
|
||||
if (taskId) activeExportTasks.add(taskId)
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
||||
let pendingProgress: ExportProgress | null = null
|
||||
let progressTimer: NodeJS.Timeout | null = null
|
||||
@@ -3090,13 +3004,17 @@ function registerIpcHandlers() {
|
||||
queueProgress(progress)
|
||||
}
|
||||
|
||||
const runMainFallback = async (reason: string) => {
|
||||
console.warn(`[fallback-export-main] ${reason}`)
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
}
|
||||
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const logEnabled = cfg.get('logEnabled')
|
||||
const dbPath = String(cfg.get('dbPath') || '').trim()
|
||||
const decryptKey = String(cfg.get('decryptKey') || '').trim()
|
||||
const myWxid = String(cfg.get('myWxid') || '').trim()
|
||||
const imageKeys = cfg.getImageKeysForCurrentWxid()
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
@@ -3110,12 +3028,9 @@ function registerIpcHandlers() {
|
||||
sessionIds,
|
||||
outputDir,
|
||||
options,
|
||||
taskId,
|
||||
dbPath,
|
||||
decryptKey,
|
||||
myWxid,
|
||||
imageXorKey: imageKeys.xorKey,
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath,
|
||||
userDataPath,
|
||||
logEnabled
|
||||
@@ -3123,15 +3038,9 @@ 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)
|
||||
@@ -3139,9 +3048,6 @@ 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)
|
||||
@@ -3152,28 +3058,6 @@ function registerIpcHandlers() {
|
||||
onProgress(msg.data as ExportProgress)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:createdFiles' && taskId) {
|
||||
const filePaths = Array.isArray(msg.filePaths) ? msg.filePaths : []
|
||||
for (const filePath of filePaths) {
|
||||
exportTaskControlService.recordCreatedFile(taskId, String(filePath || ''))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:createdDirs' && taskId) {
|
||||
const dirPaths = Array.isArray(msg.dirPaths) ? msg.dirPaths : []
|
||||
for (const dirPath of dirPaths) {
|
||||
exportTaskControlService.recordCreatedDir(taskId, String(dirPath || ''))
|
||||
}
|
||||
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
|
||||
@@ -3199,27 +3083,10 @@ function registerIpcHandlers() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runWorker()
|
||||
return await finalizeExportTaskControlResult(taskId, result)
|
||||
return await runWorker()
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker] ${errorMessage}`)
|
||||
const normalizedSessionIds = Array.isArray(sessionIds) ? sessionIds : []
|
||||
const failedSessionErrors: Record<string, string> = {}
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
failedSessionErrors[sessionId] = errorMessage
|
||||
}
|
||||
const result = {
|
||||
success: false,
|
||||
successCount: 0,
|
||||
failCount: normalizedSessionIds.length,
|
||||
failedSessionIds: normalizedSessionIds,
|
||||
failedSessionErrors,
|
||||
error: `导出 Worker 执行失败: ${errorMessage}`
|
||||
}
|
||||
return await finalizeExportTaskControlResult(taskId, result)
|
||||
return runMainFallback(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
if (taskId) activeExportTasks.delete(taskId)
|
||||
flushProgress()
|
||||
if (progressTimer) {
|
||||
clearTimeout(progressTimer)
|
||||
@@ -3228,136 +3095,12 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const imageKeys = cfg.getImageKeysForCurrentWxid()
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
try {
|
||||
return await new Promise<any>((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
mode: 'single',
|
||||
sessionId,
|
||||
outputPath,
|
||||
options,
|
||||
dbPath: String(cfg.get('dbPath') || '').trim(),
|
||||
decryptKey: String(cfg.get('decryptKey') || '').trim(),
|
||||
myWxid: String(cfg.get('myWxid') || '').trim(),
|
||||
imageXorKey: imageKeys.xorKey,
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
logEnabled: cfg.get('logEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalize = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const fail = (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-single] ${errorMessage}`)
|
||||
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:progress') {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('export:progress', msg.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalize(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
fail(String(msg.error || '导出 Worker 执行失败'))
|
||||
}
|
||||
})
|
||||
worker.on('error', fail)
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalize({ success: false, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
fail(`导出 Worker 异常退出: ${code}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-single] ${errorMessage}`)
|
||||
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
|
||||
}
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
try {
|
||||
return await new Promise<any>((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
mode: 'contacts',
|
||||
outputDir,
|
||||
options,
|
||||
dbPath: String(cfg.get('dbPath') || '').trim(),
|
||||
decryptKey: String(cfg.get('decryptKey') || '').trim(),
|
||||
myWxid: String(cfg.get('myWxid') || '').trim(),
|
||||
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
logEnabled: cfg.get('logEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalize = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const fail = (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-contacts] ${errorMessage}`)
|
||||
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalize(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
fail(String(msg.error || '导出 Worker 执行失败'))
|
||||
}
|
||||
})
|
||||
worker.on('error', fail)
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalize({ success: false, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
fail(`导出 Worker 异常退出: ${code}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-contacts] ${errorMessage}`)
|
||||
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
|
||||
}
|
||||
return contactExportService.exportContacts(outputDir, options)
|
||||
})
|
||||
|
||||
// 数据分析相关
|
||||
@@ -3997,16 +3740,7 @@ function checkForUpdatesOnStartup() {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// 先初始化配置,以便在启动早期判定是否需要静默启动
|
||||
configService = new ConfigService()
|
||||
applyAutoUpdateChannel('startup')
|
||||
syncLaunchAtStartupPreference()
|
||||
const onboardingDone = configService.get('onboardingDone') === true
|
||||
const startInBackground = onboardingDone && isSilentStartupEnabled()
|
||||
shouldShowMain = onboardingDone
|
||||
|
||||
if (!startInBackground) {
|
||||
// 非静默模式下显示 Splash,提供启动反馈
|
||||
// 立即创建 Splash 窗口,确保用户尽快看到反馈
|
||||
createSplashWindow()
|
||||
|
||||
// 等待 Splash 页面加载完成后再推送进度
|
||||
@@ -4022,7 +3756,6 @@ app.whenReady().then(async () => {
|
||||
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const withTimeout = <T>(task: () => Promise<T>, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => {
|
||||
@@ -4050,7 +3783,13 @@ 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()) {
|
||||
@@ -4217,8 +3956,6 @@ app.whenReady().then(async () => {
|
||||
|
||||
if (!onboardingDone) {
|
||||
createOnboardingWindow()
|
||||
} else if (startInBackground && tray) {
|
||||
mainWindow?.hide()
|
||||
} else {
|
||||
mainWindow?.show()
|
||||
}
|
||||
|
||||
@@ -185,7 +185,6 @@ 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'),
|
||||
@@ -463,14 +462,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
export: {
|
||||
getExportStats: (sessionIds: string[], options: any) =>
|
||||
ipcRenderer.invoke('export:getExportStats', sessionIds, 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),
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
exportContacts: (outputDir: string, options: any) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
|
||||
import { 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'
|
||||
@@ -7,12 +7,11 @@ import * as tar from 'tar'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
import { decryptDatViaNative, encryptDatViaNative } from './nativeImageDecrypt'
|
||||
|
||||
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
|
||||
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns'
|
||||
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
|
||||
@@ -141,42 +140,8 @@ 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([
|
||||
@@ -288,6 +253,27 @@ export class BackupService {
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private parseImageXorKey(value: unknown): number {
|
||||
if (typeof value === 'number') return value
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return Number.NaN
|
||||
return text.toLowerCase().startsWith('0x') ? parseInt(text, 16) : parseInt(text, 10)
|
||||
}
|
||||
|
||||
private getImageKeysForWxid(wxid: string): { xorKey: number; aesKey?: string } | null {
|
||||
const wxidConfigs = this.configService.get('wxidConfigs') || {}
|
||||
const candidates = this.buildWxidCandidates(wxid)
|
||||
const matchedKey = Object.keys(wxidConfigs).find((key) => {
|
||||
const cleanKey = this.cleanAccountDirName(key).toLowerCase()
|
||||
return candidates.some(candidate => cleanKey === candidate.toLowerCase())
|
||||
})
|
||||
const cfg = matchedKey ? wxidConfigs[matchedKey] : undefined
|
||||
const xorKey = this.parseImageXorKey(cfg?.imageXorKey ?? this.configService.get('imageXorKey'))
|
||||
if (!Number.isFinite(xorKey)) return null
|
||||
const aesKey = String(cfg?.imageAesKey ?? this.configService.get('imageAesKey') ?? '').trim()
|
||||
return { xorKey, aesKey: aesKey || undefined }
|
||||
}
|
||||
|
||||
private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise<string[]> {
|
||||
const dir = join(root, rel)
|
||||
const files: string[] = []
|
||||
@@ -309,7 +295,7 @@ export class BackupService {
|
||||
}
|
||||
|
||||
private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
|
||||
const normalized = normalizeArchivePath(archivePath)
|
||||
const normalized = String(archivePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(extractDir)
|
||||
const target = resolve(join(extractDir, normalized))
|
||||
@@ -317,12 +303,8 @@ export class BackupService {
|
||||
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)
|
||||
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(accountDir)
|
||||
const target = resolve(join(accountDir, normalized))
|
||||
@@ -369,18 +351,6 @@ export class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
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[] = []
|
||||
@@ -474,7 +444,7 @@ export class BackupService {
|
||||
}
|
||||
|
||||
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
|
||||
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') return kind
|
||||
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns') return kind
|
||||
return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
|
||||
}
|
||||
|
||||
@@ -498,7 +468,6 @@ export class BackupService {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -548,19 +517,12 @@ export class BackupService {
|
||||
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) {
|
||||
for (const kind of ['session', 'contact', 'emoticon', 'sns'] as const) {
|
||||
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
|
||||
result.push({
|
||||
id: kind,
|
||||
@@ -603,9 +565,11 @@ export class BackupService {
|
||||
manifest: BackupManifest
|
||||
): Promise<void> {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
const keys = this.getImageKeysForWxid(connected.wxid)
|
||||
const imagesDir = join(stagingDir, 'resources', 'images')
|
||||
const imagePaths = await this.listChatImageDatFiles(accountDir)
|
||||
if (imagePaths.length === 0) return
|
||||
if (!keys) throw new Error('存在图片资源,但未配置图片解密密钥')
|
||||
|
||||
mkdirSync(imagesDir, { recursive: true })
|
||||
const resources: BackupResourceEntry[] = []
|
||||
@@ -616,16 +580,18 @@ export class BackupService {
|
||||
if (!relativeTarget) continue
|
||||
emitImageProgress({
|
||||
phase: 'exporting',
|
||||
message: '正在打包图片资源',
|
||||
message: '正在解密图片资源',
|
||||
current: index + 1,
|
||||
total: imagePaths.length,
|
||||
detail: relativeTarget
|
||||
})
|
||||
const archivePath = toArchivePath(join('resources', 'images', relativeTarget))
|
||||
const decrypted = decryptDatViaNative(sourcePath, keys.xorKey, keys.aesKey)
|
||||
if (!decrypted) continue
|
||||
const archivePath = toArchivePath(join('resources', 'images', `${relativeTarget}${decrypted.ext || '.bin'}`))
|
||||
const outputPath = join(stagingDir, archivePath)
|
||||
await this.stagePlainResource(sourcePath, outputPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, decrypted.data)
|
||||
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
|
||||
const stat = statSync(sourcePath)
|
||||
resources.push({
|
||||
kind: 'image',
|
||||
id: relativeTarget,
|
||||
@@ -633,7 +599,8 @@ export class BackupService {
|
||||
sourceFileName: basename(sourcePath),
|
||||
archivePath,
|
||||
targetRelativePath: relativeTarget,
|
||||
size: stat.size
|
||||
ext: decrypted.ext || undefined,
|
||||
size: decrypted.data.length
|
||||
})
|
||||
if (index % 20 === 0) await delay()
|
||||
}
|
||||
@@ -709,7 +676,7 @@ export class BackupService {
|
||||
return { success: false, error: connected.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
stagingDir = await this.createTempDir('weflow-backup-')
|
||||
stagingDir = await mkdtemp(join(tmpdir(), 'weflow-backup-'))
|
||||
const snapshotsDir = join(stagingDir, 'snapshots')
|
||||
mkdirSync(snapshotsDir, { recursive: true })
|
||||
|
||||
@@ -847,7 +814,7 @@ export class BackupService {
|
||||
let extractDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
|
||||
extractDir = await this.createTempDir('weflow-backup-inspect-')
|
||||
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-inspect-'))
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: extractDir,
|
||||
@@ -869,119 +836,12 @@ export class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
emitBackupProgress({ phase: 'inspecting', message: '正在解包备份' })
|
||||
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-restore-'))
|
||||
await tar.x({ file: archivePath, cwd: extractDir })
|
||||
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
|
||||
@@ -1006,26 +866,6 @@ export class BackupService {
|
||||
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)
|
||||
@@ -1067,6 +907,68 @@ export class BackupService {
|
||||
if (current % 4 === 0) await delay()
|
||||
}
|
||||
|
||||
if (imageJobs.length > 0) {
|
||||
const targetWxid = connected.wxid || String(manifest.source?.wxid || '').trim()
|
||||
const imageKeys = this.getImageKeysForWxid(targetWxid)
|
||||
if (!imageKeys) throw new Error('备份包包含图片资源,但目标账号未配置图片加密密钥')
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
for (const image of imageJobs) {
|
||||
current += 1
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: '正在加密并写回图片资源',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: image.md5 || image.targetRelativePath
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, image.archivePath)
|
||||
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
|
||||
if (!inputPath || !targetPath || !existsSync(inputPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if (existsSync(targetPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
const encrypted = encryptDatViaNative(inputPath, imageKeys.xorKey, imageKeys.aesKey)
|
||||
if (!encrypted) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
await writeFile(targetPath, encrypted)
|
||||
if (current % 16 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
if (plainResourceJobs.length > 0) {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
for (const resource of plainResourceJobs) {
|
||||
current += 1
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: resource.targetRelativePath
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, resource.archivePath)
|
||||
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
|
||||
if (!inputPath || !targetPath || !existsSync(inputPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if (existsSync(targetPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
await copyFile(inputPath, targetPath)
|
||||
if (current % 30 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
|
||||
return { success: true, inserted, ignored, skipped }
|
||||
} catch (e) {
|
||||
|
||||
@@ -666,9 +666,6 @@ 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
|
||||
@@ -712,7 +709,6 @@ class ChatService {
|
||||
console.error('ChatService: 关闭数据库失败:', e)
|
||||
}
|
||||
this.connected = false
|
||||
this.monitorSetup = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -749,12 +745,8 @@ class ChatService {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||
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] }
|
||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds)
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
@@ -768,12 +760,8 @@ class ChatService {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||
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] }
|
||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds)
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
@@ -787,12 +775,8 @@ class ChatService {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||
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] }
|
||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds)
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
@@ -950,191 +934,6 @@ 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 {
|
||||
|
||||
@@ -36,7 +36,6 @@ interface ConfigSchema {
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
launchAtStartup?: boolean
|
||||
silentStartup?: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
@@ -164,7 +163,6 @@ export class ConfigService {
|
||||
themeId: 'cloud-dancer',
|
||||
language: 'zh-CN',
|
||||
logEnabled: false,
|
||||
silentStartup: false,
|
||||
llmModelPath: '',
|
||||
whisperModelName: 'base',
|
||||
whisperModelDir: '',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
||||
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()
|
||||
@@ -81,7 +81,6 @@ export class ImageDecryptService {
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
private nativeLogged = false
|
||||
private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
|
||||
private datNameScanMissAt = new Map<string, number>()
|
||||
private readonly datNameScanMissTtlMs = 1200
|
||||
private readonly accountDirCache = new Map<string, string>()
|
||||
@@ -100,32 +99,6 @@ export class ImageDecryptService {
|
||||
return this.shouldEmitImageEvents(payload)
|
||||
}
|
||||
|
||||
setRuntimeConfig(config: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
|
||||
this.runtimeConfig = config
|
||||
}
|
||||
|
||||
private getConfiguredDbPath(): string {
|
||||
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||
}
|
||||
|
||||
private getConfiguredMyWxid(): string {
|
||||
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||
}
|
||||
|
||||
private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } {
|
||||
const runtimeImageXorKey = this.runtimeConfig?.imageXorKey
|
||||
const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== ''
|
||||
const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim()
|
||||
if (hasRuntimeXorKey || runtimeAesKey) {
|
||||
const fallback = this.configService.getImageKeysForCurrentWxid()
|
||||
return {
|
||||
xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey,
|
||||
aesKey: runtimeAesKey || fallback.aesKey
|
||||
}
|
||||
}
|
||||
return this.configService.getImageKeysForCurrentWxid()
|
||||
}
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
const timestamp = new Date().toISOString()
|
||||
@@ -293,8 +266,8 @@ export class ImageDecryptService {
|
||||
)
|
||||
if (normalizedList.length === 0) return
|
||||
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) return
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
@@ -321,8 +294,8 @@ export class ImageDecryptService {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
|
||||
try {
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||
@@ -431,7 +404,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
|
||||
const imageKeys = this.getConfiguredImageKeys()
|
||||
const imageKeys = this.configService.getImageKeysForCurrentWxid()
|
||||
const xorKeyRaw = imageKeys.xorKey
|
||||
// 支持十六进制格式(如 0x53)和十进制格式
|
||||
let xorKey: number
|
||||
@@ -454,7 +427,7 @@ export class ImageDecryptService {
|
||||
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
|
||||
const aesKeyForNative = aesKeyText || undefined
|
||||
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
|
||||
if (!nativeResult) {
|
||||
@@ -554,8 +527,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private resolveCurrentAccountDir(): string | null {
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) return null
|
||||
return this.resolveAccountDir(dbPath, wxid)
|
||||
}
|
||||
@@ -1578,117 +1551,7 @@ export class ImageDecryptService {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (result) return result
|
||||
const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
|
||||
if (fallback) {
|
||||
this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext })
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private tryDecryptDatWithJs(
|
||||
datPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
try {
|
||||
const encrypted = readFileSync(datPath)
|
||||
const directExt = this.detectImageExtension(encrypted)
|
||||
if (directExt) return { data: encrypted, ext: directExt, isWxgf: false }
|
||||
|
||||
const candidates: Buffer[] = []
|
||||
const aesKeyText = String(aesKey || '').trim()
|
||||
const datVersion = this.getDatVersion(encrypted)
|
||||
if (datVersion === 2 && aesKeyText.length >= 16) {
|
||||
try {
|
||||
candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16)))
|
||||
} catch { }
|
||||
}
|
||||
if (datVersion !== 2) {
|
||||
candidates.push(this.decryptDatV3WithJs(encrypted, xorKey))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const ext = this.detectImageExtension(candidate)
|
||||
if (ext) return { data: candidate, ext, isWxgf: false }
|
||||
}
|
||||
} catch (error) {
|
||||
this.logError('JS DAT 解密 fallback 失败', error, { datPath })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer {
|
||||
const output = Buffer.allocUnsafe(data.length)
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
output[i] = data[i] ^ xorKey
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer {
|
||||
if (data.length < 0x0f) {
|
||||
throw new Error('dat file too small')
|
||||
}
|
||||
const header = data.subarray(0, 0x0f)
|
||||
const payload = data.subarray(0x0f)
|
||||
const aesSize = this.readInt32LeSafe(header, 6)
|
||||
const xorSize = this.readInt32LeSafe(header, 10)
|
||||
const remainder = ((aesSize % 16) + 16) % 16
|
||||
const alignedAesSize = aesSize + (16 - remainder)
|
||||
if (alignedAesSize > payload.length) throw new Error('invalid aes size')
|
||||
|
||||
const aesData = payload.subarray(0, alignedAesSize)
|
||||
|
||||
let plainAes = Buffer.alloc(0)
|
||||
if (aesData.length > 0) {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
|
||||
decipher.setAutoPadding(false)
|
||||
plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()]))
|
||||
}
|
||||
|
||||
const remaining = payload.subarray(alignedAesSize)
|
||||
if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size')
|
||||
|
||||
let rawData = Buffer.alloc(0)
|
||||
let decodedXor = Buffer.alloc(0)
|
||||
if (xorSize > 0) {
|
||||
const rawLength = remaining.length - xorSize
|
||||
if (rawLength < 0) throw new Error('invalid raw size')
|
||||
rawData = remaining.subarray(0, rawLength)
|
||||
const xorData = remaining.subarray(rawLength)
|
||||
decodedXor = Buffer.allocUnsafe(xorData.length)
|
||||
for (let i = 0; i < xorData.length; i += 1) {
|
||||
decodedXor[i] = xorData[i] ^ xorKey
|
||||
}
|
||||
} else {
|
||||
rawData = remaining
|
||||
}
|
||||
return Buffer.concat([plainAes, rawData, decodedXor])
|
||||
}
|
||||
|
||||
private getDatVersion(data: Buffer): number {
|
||||
if (data.length < 6) return 0
|
||||
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
|
||||
const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
if (data.subarray(0, 6).equals(sigV1)) return 1
|
||||
if (data.subarray(0, 6).equals(sigV2)) return 2
|
||||
return 0
|
||||
}
|
||||
|
||||
private readInt32LeSafe(buffer: Buffer, offset: number): number {
|
||||
if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset')
|
||||
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)
|
||||
}
|
||||
|
||||
private strictRemovePkcs7Padding(data: Buffer): Buffer {
|
||||
if (data.length === 0) throw new Error('empty decrypted data')
|
||||
const pad = data[data.length - 1]
|
||||
if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding')
|
||||
for (let i = data.length - pad; i < data.length; i += 1) {
|
||||
if (data[i] !== pad) throw new Error('invalid pkcs7 padding')
|
||||
}
|
||||
return data.subarray(0, data.length - pad)
|
||||
return result
|
||||
}
|
||||
|
||||
private detectImageExtension(buffer: Buffer): string | null {
|
||||
|
||||
@@ -167,7 +167,7 @@ export class KeyServiceLinux {
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
|
||||
return await this.getDbKey(pid, onStatus, timeoutMs)
|
||||
return await this.getDbKey(pid, onStatus)
|
||||
} 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, timeoutMs = 180_000): Promise<DbKeyResult> {
|
||||
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
|
||||
try {
|
||||
const helperPath = this.getHelperPath()
|
||||
|
||||
@@ -200,56 +200,28 @@ export class KeyServiceLinux {
|
||||
}
|
||||
|
||||
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)
|
||||
const options = { name: 'WeFlow' }
|
||||
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
|
||||
|
||||
onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0)
|
||||
|
||||
this.sudo.exec(command, options, (error, stdout, stderr) => {
|
||||
this.sudo.exec(command, options, (error, stdout) => {
|
||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||
if (error) {
|
||||
const detail = String(stderr || '').trim()
|
||||
const message = detail ? `${error.message}: ${detail}` : error.message
|
||||
onStatus?.('授权失败或 Hook 执行失败', 2)
|
||||
finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` })
|
||||
onStatus?.('授权失败或被取消', 2)
|
||||
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const output = String(stdout || '').trim()
|
||||
if (!output) {
|
||||
const detail = String(stderr || '').trim()
|
||||
throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出')
|
||||
}
|
||||
const hookRes = JSON.parse(output)
|
||||
const hookRes = JSON.parse((stdout as string).trim())
|
||||
if (hookRes.success) {
|
||||
onStatus?.('密钥获取成功', 1)
|
||||
finish({ success: true, key: hookRes.key })
|
||||
resolve({ success: true, key: hookRes.key })
|
||||
} else {
|
||||
onStatus?.(hookRes.result, 2)
|
||||
finish({ success: false, error: hookRes.result })
|
||||
resolve({ success: false, error: hookRes.result })
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
onStatus?.('解析 Hook 结果失败', 2)
|
||||
finish({ success: false, error: e?.message || '解析 Hook 结果失败' })
|
||||
resolve({ success: false, error: '解析 Hook 结果失败' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 阶段。请确认微信处于可交互状态并重试。'
|
||||
|
||||
@@ -6,30 +6,11 @@ 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
|
||||
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string) => Buffer
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
@@ -111,7 +92,7 @@ export function decryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null {
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon) return null
|
||||
|
||||
@@ -123,14 +104,7 @@ export function decryptDatViaNative(
|
||||
? result.ext.trim().toLowerCase()
|
||||
: ''
|
||||
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||
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 }
|
||||
return { data: result.data, ext, isWxgf }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -139,14 +113,13 @@ export function decryptDatViaNative(
|
||||
export function encryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string,
|
||||
meta?: NativeDatMeta
|
||||
aesKey?: string
|
||||
): Buffer | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon || typeof addon.encryptDatNative !== 'function') return null
|
||||
|
||||
try {
|
||||
const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta)
|
||||
const result = addon.encryptDatNative(inputPath, xorKey, aesKey)
|
||||
return Buffer.isBuffer(result) ? result : null
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -1340,8 +1340,6 @@ 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 =
|
||||
@@ -1363,18 +1361,6 @@ 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 }
|
||||
@@ -1383,7 +1369,9 @@ class SnsService {
|
||||
|
||||
try {
|
||||
// 确保输出目录存在
|
||||
ensureExportDir(outputDir)
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 1. 分页加载全部帖子
|
||||
const allPosts: SnsPost[] = []
|
||||
@@ -1426,7 +1414,9 @@ class SnsService {
|
||||
const mediaDir = join(outputDir, 'media')
|
||||
|
||||
if (shouldExportMedia) {
|
||||
ensureExportDir(mediaDir)
|
||||
if (!existsSync(mediaDir)) {
|
||||
mkdirSync(mediaDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 收集所有媒体下载任务
|
||||
const mediaTasks: Array<{
|
||||
@@ -1495,7 +1485,6 @@ 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}`
|
||||
@@ -1505,7 +1494,6 @@ 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}`
|
||||
@@ -1543,7 +1531,7 @@ class SnsService {
|
||||
// 2.5 下载头像
|
||||
const avatarMap = new Map<string, string>()
|
||||
if (format === 'html') {
|
||||
ensureExportDir(mediaDir)
|
||||
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
||||
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||||
let avatarDone = 0
|
||||
const avatarQueue = [...uniqueUsers]
|
||||
@@ -1560,7 +1548,6 @@ 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}`)
|
||||
}
|
||||
@@ -1615,7 +1602,6 @@ 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`)
|
||||
@@ -1703,13 +1689,11 @@ 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')
|
||||
}
|
||||
|
||||
|
||||
@@ -92,9 +92,6 @@ 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
|
||||
|
||||
1286
package-lock.json
generated
1286
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.0.0",
|
||||
"vite": "^8.0.10",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -80,7 +80,7 @@ import {
|
||||
import './ExportPage.scss'
|
||||
|
||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||
type TaskStatus = 'queued' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested' | 'success' | 'error'
|
||||
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
|
||||
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
||||
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||
type ContentCardType = ContentType | 'sns'
|
||||
@@ -578,27 +578,10 @@ 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'
|
||||
@@ -1826,9 +1809,6 @@ 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
|
||||
@@ -1844,9 +1824,6 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
nowTick,
|
||||
onClose,
|
||||
onTogglePerfTask,
|
||||
onPauseExportTask,
|
||||
onResumeExportTask,
|
||||
onCancelExportTask,
|
||||
onPauseBackgroundTask,
|
||||
onResumeBackgroundTask,
|
||||
onCancelBackgroundTask
|
||||
@@ -1977,31 +1954,15 @@ 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 ${taskCardClass}`}>
|
||||
<div key={task.id} className={`task-card ${task.status}`}>
|
||||
<div className="task-main">
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-meta">
|
||||
<span className={`task-status ${taskCardClass}`}>{getTaskStatusLabel(task)}</span>
|
||||
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
|
||||
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
{canShowProgress && (
|
||||
{task.status === 'running' && (
|
||||
<>
|
||||
<div className="task-progress-bar">
|
||||
<div
|
||||
@@ -2089,34 +2050,6 @@ 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={() => {
|
||||
@@ -5192,7 +5125,6 @@ function ExportPage() {
|
||||
exportConcurrency: sourceOptions.exportConcurrency,
|
||||
fileNamingMode: exportDefaultFileNamingMode,
|
||||
sessionLayout,
|
||||
exportWriteLayout: writeLayout,
|
||||
sessionNameWithTypePrefix,
|
||||
dateRange: sourceOptions.useAllTime
|
||||
? null
|
||||
@@ -5654,7 +5586,7 @@ function ExportPage() {
|
||||
const now = Date.now()
|
||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||
updateTask(next.id, task => {
|
||||
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') return task
|
||||
if (task.status !== 'running') return task
|
||||
const performance = applyProgressToTaskPerformance(task, payload, now)
|
||||
const settledSessionIds = task.settledSessionIds || []
|
||||
const nextSettledSessionIds = (
|
||||
@@ -5808,8 +5740,7 @@ function ExportPage() {
|
||||
exportLivePhotos: snsOptions.exportLivePhotos,
|
||||
exportVideos: snsOptions.exportVideos,
|
||||
startTime: snsOptions.startTime,
|
||||
endTime: snsOptions.endTime,
|
||||
taskId: next.id
|
||||
endTime: snsOptions.endTime
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
@@ -5820,19 +5751,6 @@ 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)
|
||||
@@ -5864,8 +5782,7 @@ function ExportPage() {
|
||||
const result = await window.electronAPI.export.exportSessions(
|
||||
next.payload.sessionIds,
|
||||
next.payload.outputDir,
|
||||
next.payload.options,
|
||||
{ taskId: next.id }
|
||||
next.payload.options
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
@@ -5876,33 +5793,6 @@ 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
|
||||
@@ -6009,10 +5899,9 @@ function ExportPage() {
|
||||
}
|
||||
return {
|
||||
...task.template.optionTemplate,
|
||||
exportWriteLayout: task.template.optionTemplate.exportWriteLayout || writeLayout,
|
||||
dateRange
|
||||
}
|
||||
}, [writeLayout])
|
||||
}, [])
|
||||
|
||||
const enqueueAutomationTask = useCallback((
|
||||
task: ExportAutomationTask,
|
||||
@@ -6024,13 +5913,7 @@ function ExportPage() {
|
||||
}
|
||||
|
||||
const hasConflict = tasksRef.current.some((item) => {
|
||||
if (
|
||||
item.status !== 'running' &&
|
||||
item.status !== 'queued' &&
|
||||
item.status !== 'pause_requested' &&
|
||||
item.status !== 'paused' &&
|
||||
item.status !== 'cancel_requested'
|
||||
) return false
|
||||
if (item.status !== 'running' && item.status !== 'queued') return false
|
||||
return item.payload.automationTaskId === task.id
|
||||
})
|
||||
if (hasConflict) {
|
||||
@@ -6317,7 +6200,7 @@ function ExportPage() {
|
||||
const runningSessionIds = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') continue
|
||||
if (task.status !== 'running') continue
|
||||
const settled = new Set(task.settledSessionIds || [])
|
||||
for (const id of task.payload.sessionIds) {
|
||||
if (settled.has(id)) continue
|
||||
@@ -6330,7 +6213,7 @@ function ExportPage() {
|
||||
const queuedSessionIds = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
if (task.status !== 'queued' && task.status !== 'paused') continue
|
||||
if (task.status !== 'queued') continue
|
||||
for (const id of task.payload.sessionIds) {
|
||||
set.add(id)
|
||||
}
|
||||
@@ -6341,7 +6224,7 @@ function ExportPage() {
|
||||
const inProgressSessionIds = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const task of tasks) {
|
||||
if (!isExportTaskActiveStatus(task.status)) continue
|
||||
if (task.status !== 'running' && task.status !== 'queued') continue
|
||||
for (const id of task.payload.sessionIds) {
|
||||
set.add(id)
|
||||
}
|
||||
@@ -6349,7 +6232,7 @@ function ExportPage() {
|
||||
return Array.from(set).sort()
|
||||
}, [tasks])
|
||||
const activeTaskCount = useMemo(
|
||||
() => tasks.filter(task => isExportTaskActiveStatus(task.status)).length,
|
||||
() => tasks.filter(task => task.status === 'running' || task.status === 'queued').length,
|
||||
[tasks]
|
||||
)
|
||||
|
||||
@@ -6364,7 +6247,7 @@ function ExportPage() {
|
||||
if (previousStatus === task.status) continue
|
||||
|
||||
const now = Date.now()
|
||||
if (task.status === 'running' || task.status === 'pause_requested' || task.status === 'paused' || task.status === 'cancel_requested') {
|
||||
if (task.status === 'running') {
|
||||
patchAutomationTask(automationTaskId, (current) => ({
|
||||
...current,
|
||||
updatedAt: now,
|
||||
@@ -6455,13 +6338,7 @@ function ExportPage() {
|
||||
if (task.runState?.lastScheduleKey === scheduleKey) continue
|
||||
|
||||
const hasConflict = tasksRef.current.some((item) => {
|
||||
if (
|
||||
item.status !== 'running' &&
|
||||
item.status !== 'queued' &&
|
||||
item.status !== 'pause_requested' &&
|
||||
item.status !== 'paused' &&
|
||||
item.status !== 'cancel_requested'
|
||||
) return false
|
||||
if (item.status !== 'running' && item.status !== 'queued') return false
|
||||
return item.payload.automationTaskId === task.id
|
||||
})
|
||||
if (hasConflict) {
|
||||
@@ -6571,7 +6448,7 @@ function ExportPage() {
|
||||
const runningCardTypes = useMemo(() => {
|
||||
const set = new Set<ContentCardType>()
|
||||
for (const task of tasks) {
|
||||
if (!isExportTaskActiveStatus(task.status)) continue
|
||||
if (task.status !== 'running') continue
|
||||
if (task.payload.scope === 'sns') {
|
||||
set.add('sns')
|
||||
continue
|
||||
@@ -8014,12 +7891,7 @@ function ExportPage() {
|
||||
)
|
||||
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||
const taskRunningCount = tasks.filter(task => (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)).length
|
||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||
const chatBackgroundTasks = useMemo(() => (
|
||||
backgroundTasks.filter(task => task.sourcePage === 'chat')
|
||||
@@ -8233,112 +8105,6 @@ 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()
|
||||
@@ -8798,9 +8564,6 @@ function ExportPage() {
|
||||
nowTick={nowTick}
|
||||
onClose={closeTaskCenter}
|
||||
onTogglePerfTask={toggleTaskPerfDetail}
|
||||
onPauseExportTask={handlePauseExportTask}
|
||||
onResumeExportTask={handleResumeExportTask}
|
||||
onCancelExportTask={handleCancelExportTask}
|
||||
onPauseBackgroundTask={handlePauseBackgroundTask}
|
||||
onResumeBackgroundTask={handleResumeBackgroundTask}
|
||||
onCancelBackgroundTask={handleCancelBackgroundTask}
|
||||
@@ -8859,12 +8622,12 @@ function ExportPage() {
|
||||
<div className="automation-task-list">
|
||||
{sortedAutomationTasks.map((task) => {
|
||||
const linkedQueueTask = tasks.find((item) => (
|
||||
isExportTaskActiveStatus(item.status) &&
|
||||
(item.status === 'running' || item.status === 'queued') &&
|
||||
item.payload.automationTaskId === task.id
|
||||
))
|
||||
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
|
||||
? 'running'
|
||||
: linkedQueueTask && isExportTaskActiveStatus(linkedQueueTask.status)
|
||||
: linkedQueueTask?.status === 'queued'
|
||||
? 'queued'
|
||||
: null
|
||||
return (
|
||||
|
||||
@@ -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 { ChatSession, ContactInfo } from '../types/models'
|
||||
import type { ContactInfo } from '../types/models'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
@@ -195,7 +195,6 @@ 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')
|
||||
@@ -223,7 +222,6 @@ 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)
|
||||
@@ -265,7 +263,6 @@ 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 }>>({})
|
||||
@@ -448,7 +445,6 @@ 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()
|
||||
@@ -506,7 +502,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||
setSilentStartup(savedSilentStartup)
|
||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||
setQuoteLayout(savedQuoteLayout)
|
||||
if (savedUpdateChannel) {
|
||||
@@ -620,21 +615,6 @@ 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()
|
||||
@@ -772,10 +752,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
|
||||
const getCurrentAntiRevokeSessionIds = (): string[] =>
|
||||
normalizeSessionIds(antiRevokeSessions.map((session) => session.username))
|
||||
normalizeSessionIds(chatSessions.map((session) => session.username))
|
||||
|
||||
const ensureChatSessionsLoaded = async (): Promise<string[]> => {
|
||||
const current = normalizeSessionIds(chatSessions.map((session) => session.username))
|
||||
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
|
||||
const current = getCurrentAntiRevokeSessionIds()
|
||||
if (current.length > 0) return current
|
||||
const sessionsResult = await window.electronAPI.chat.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
@@ -785,27 +765,6 @@ 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 }
|
||||
@@ -1017,10 +976,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
let canceled = false
|
||||
;(async () => {
|
||||
try {
|
||||
// 两个 Tab 都需要会话列表;antiRevoke 还需要额外检查防撤回状态
|
||||
const sessionIds = await ensureAntiRevokeSessionsLoaded()
|
||||
if (canceled) return
|
||||
if (activeTab === 'antiRevoke') {
|
||||
await ensureAntiRevokeSessionsLoaded()
|
||||
} else {
|
||||
await ensureChatSessionsLoaded()
|
||||
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!canceled) {
|
||||
@@ -1724,35 +1684,6 @@ 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>
|
||||
@@ -2051,7 +1982,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
|
||||
const renderAntiRevokeTab = () => {
|
||||
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
|
||||
const filteredSessions = sortedSessions.filter((session) => {
|
||||
if (!keyword) return true
|
||||
@@ -4830,3 +4761,4 @@ export default SettingsPage
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2729,54 +2729,6 @@
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export-progress-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.export-progress-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 82px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border-color: color-mix(in srgb, #ff4d4f 36%, var(--border-color));
|
||||
background: color-mix(in srgb, #ff4d4f 10%, var(--bg-secondary));
|
||||
color: #d9363e;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-result {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2 } from 'lucide-react'
|
||||
import './SnsPage.scss'
|
||||
import { SnsPost } from '../types/sns'
|
||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||
@@ -64,42 +64,10 @@ interface SnsOverviewStats {
|
||||
|
||||
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
|
||||
type SnsExportTaskStatus = 'idle' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested'
|
||||
|
||||
interface SnsExportProgress {
|
||||
current: number
|
||||
total: number
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SnsExportResult {
|
||||
success: boolean
|
||||
filePath?: string
|
||||
postCount?: number
|
||||
mediaCount?: number
|
||||
paused?: boolean
|
||||
stopped?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface SnsExportRequest {
|
||||
taskId: string
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportImages: boolean
|
||||
exportLivePhotos: boolean
|
||||
exportVideos: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1'
|
||||
|
||||
const createSnsExportTaskId = (): string => `sns-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
interface SnsCacheMigrationItem {
|
||||
label: string
|
||||
sourceDir: string
|
||||
@@ -211,9 +179,8 @@ export default function SnsPage() {
|
||||
() => createExportDateRangeSelectionFromPreset('all')
|
||||
)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportTaskStatus, setExportTaskStatus] = useState<SnsExportTaskStatus>('idle')
|
||||
const [exportProgress, setExportProgress] = useState<SnsExportProgress | null>(null)
|
||||
const [exportResult, setExportResult] = useState<SnsExportResult | null>(null)
|
||||
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
|
||||
const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null)
|
||||
const [refreshSpin, setRefreshSpin] = useState(false)
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
|
||||
@@ -244,8 +211,6 @@ export default function SnsPage() {
|
||||
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
||||
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
|
||||
const activeContactsCountTaskIdRef = useRef<string | null>(null)
|
||||
const activeExportTaskIdRef = useRef<string | null>(null)
|
||||
const activeExportRequestRef = useRef<SnsExportRequest | null>(null)
|
||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||
const pendingResetFeedRef = useRef(false)
|
||||
const contactsLoadTokenRef = useRef(0)
|
||||
@@ -500,11 +465,7 @@ export default function SnsPage() {
|
||||
: overviewStatsStatus === 'loading' || contactsLoading
|
||||
)
|
||||
|
||||
const isExportLocked = isExporting || exportTaskStatus !== 'idle'
|
||||
const canPauseExport = exportTaskStatus === 'running'
|
||||
const canResumeExport = exportTaskStatus === 'paused' || exportTaskStatus === 'pause_requested'
|
||||
const canCancelExport = exportTaskStatus !== 'idle'
|
||||
const canStartExport = Boolean(exportFolder) && !isExportLocked && (
|
||||
const canStartExport = Boolean(exportFolder) && !isExporting && (
|
||||
exportScope.kind === 'all' || exportScope.usernames.length > 0
|
||||
)
|
||||
|
||||
@@ -811,205 +772,14 @@ export default function SnsPage() {
|
||||
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
|
||||
|
||||
const clearActiveExportTask = useCallback(() => {
|
||||
activeExportTaskIdRef.current = null
|
||||
activeExportRequestRef.current = null
|
||||
setExportTaskStatus('idle')
|
||||
setIsExporting(false)
|
||||
}, [])
|
||||
|
||||
const buildSnsExportRequest = useCallback((taskId: string): SnsExportRequest => ({
|
||||
taskId,
|
||||
outputDir: exportFolder,
|
||||
format: exportFormat,
|
||||
usernames: exportScope.kind === 'selected' ? [...exportScope.usernames] : undefined,
|
||||
keyword: searchKeyword || undefined,
|
||||
exportImages,
|
||||
exportLivePhotos,
|
||||
exportVideos,
|
||||
startTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
|
||||
endTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
|
||||
}), [
|
||||
exportDateRangeSelection,
|
||||
exportFolder,
|
||||
exportFormat,
|
||||
exportImages,
|
||||
exportLivePhotos,
|
||||
exportScope,
|
||||
exportVideos,
|
||||
searchKeyword
|
||||
])
|
||||
|
||||
const runSnsExport = useCallback(async (request: SnsExportRequest, statusText = '准备导出...') => {
|
||||
activeExportTaskIdRef.current = request.taskId
|
||||
activeExportRequestRef.current = request
|
||||
setIsExporting(true)
|
||||
setExportTaskStatus('running')
|
||||
setExportResult(null)
|
||||
setExportProgress(prev => prev || { current: 0, total: 0, status: statusText })
|
||||
|
||||
let keepTaskActive = false
|
||||
const removeProgress = window.electronAPI.sns.onExportProgress((progress: SnsExportProgress) => {
|
||||
setExportProgress(progress)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sns.exportTimeline(request)
|
||||
if (!result.success) {
|
||||
setExportResult(result)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.paused) {
|
||||
keepTaskActive = true
|
||||
setExportTaskStatus('paused')
|
||||
setExportProgress(prev => ({
|
||||
current: Math.max(prev?.current || 0, result.postCount || 0),
|
||||
total: Math.max(prev?.total || 0, result.postCount || 0),
|
||||
status: '已暂停,可继续或取消'
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (result.stopped) {
|
||||
setExportResult(null)
|
||||
setExportProgress(null)
|
||||
setShowExportDialog(false)
|
||||
return
|
||||
}
|
||||
|
||||
setExportResult(result)
|
||||
} catch (e: any) {
|
||||
setExportResult({ success: false, error: e.message || String(e) })
|
||||
} finally {
|
||||
removeProgress()
|
||||
setIsExporting(false)
|
||||
if (!keepTaskActive) {
|
||||
activeExportTaskIdRef.current = null
|
||||
activeExportRequestRef.current = null
|
||||
setExportTaskStatus('idle')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStartSnsExport = useCallback(() => {
|
||||
if (!canStartExport) return
|
||||
const request = buildSnsExportRequest(createSnsExportTaskId())
|
||||
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
||||
void runSnsExport(request)
|
||||
}, [buildSnsExportRequest, canStartExport, runSnsExport])
|
||||
|
||||
const handlePauseSnsExport = useCallback(() => {
|
||||
const taskId = activeExportTaskIdRef.current
|
||||
if (!taskId || exportTaskStatus !== 'running') return
|
||||
setExportTaskStatus('pause_requested')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: '暂停请求已发送,正在等待安全检查点'
|
||||
}))
|
||||
window.electronAPI.export.pauseTask(taskId).then(result => {
|
||||
if (result.success) return
|
||||
setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current)
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: result.error || '暂停请求失败'
|
||||
}))
|
||||
}).catch(error => {
|
||||
setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current)
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: String(error)
|
||||
}))
|
||||
})
|
||||
}, [exportTaskStatus])
|
||||
|
||||
const handleResumeSnsExport = useCallback(() => {
|
||||
const taskId = activeExportTaskIdRef.current
|
||||
const request = activeExportRequestRef.current
|
||||
if (!taskId || !request || (exportTaskStatus !== 'paused' && exportTaskStatus !== 'pause_requested')) return
|
||||
setExportTaskStatus('running')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: '正在继续导出...'
|
||||
}))
|
||||
window.electronAPI.export.resumeTask(taskId).then(result => {
|
||||
if (!result.success) {
|
||||
setExportTaskStatus('paused')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: result.error || '继续任务失败'
|
||||
}))
|
||||
return
|
||||
}
|
||||
void runSnsExport(request, '正在继续导出...')
|
||||
}).catch(error => {
|
||||
setExportTaskStatus('paused')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: String(error)
|
||||
}))
|
||||
})
|
||||
}, [exportTaskStatus, runSnsExport])
|
||||
|
||||
const handleCancelSnsExport = useCallback(() => {
|
||||
const taskId = activeExportTaskIdRef.current
|
||||
if (!taskId || exportTaskStatus === 'idle' || exportTaskStatus === 'cancel_requested') return
|
||||
const shouldCloseAfterAck = exportTaskStatus === 'paused' || !isExporting
|
||||
setExportTaskStatus('cancel_requested')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: '取消请求已发送,正在安全停止并清理'
|
||||
}))
|
||||
window.electronAPI.export.cancelTask(taskId).then(result => {
|
||||
if (!result.success) {
|
||||
setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: result.error || '取消任务失败'
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (shouldCloseAfterAck) {
|
||||
clearActiveExportTask()
|
||||
setExportResult(null)
|
||||
setExportProgress(null)
|
||||
setShowExportDialog(false)
|
||||
}
|
||||
}).catch(error => {
|
||||
setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running')
|
||||
setExportProgress(prev => ({
|
||||
current: prev?.current || 0,
|
||||
total: prev?.total || 0,
|
||||
status: String(error)
|
||||
}))
|
||||
})
|
||||
}, [clearActiveExportTask, exportTaskStatus, isExporting])
|
||||
|
||||
const openExportDialog = useCallback((scope: SnsExportScope) => {
|
||||
if (isExportLocked) {
|
||||
setShowExportDialog(true)
|
||||
return
|
||||
}
|
||||
setExportScope(scope)
|
||||
setExportResult(null)
|
||||
setExportProgress(null)
|
||||
clearActiveExportTask()
|
||||
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
setShowExportDialog(true)
|
||||
}, [clearActiveExportTask, isExportLocked])
|
||||
}, [])
|
||||
|
||||
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
||||
const { reset = false, direction = 'older' } = options
|
||||
@@ -2278,11 +2048,11 @@ export default function SnsPage() {
|
||||
|
||||
{/* 导出对话框 */}
|
||||
{showExportDialog && (
|
||||
<div className="modal-overlay" onClick={() => !isExportLocked && setShowExportDialog(false)}>
|
||||
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
|
||||
<div className="export-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="export-dialog-header">
|
||||
<h3>导出朋友圈</h3>
|
||||
<button className="close-btn" onClick={() => !isExportLocked && setShowExportDialog(false)} disabled={isExportLocked}>
|
||||
<button className="close-btn" onClick={() => !isExporting && setShowExportDialog(false)} disabled={isExporting}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -2308,7 +2078,7 @@ export default function SnsPage() {
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'html' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('html')}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<FileText size={20} />
|
||||
<span>HTML</span>
|
||||
@@ -2317,7 +2087,7 @@ export default function SnsPage() {
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('json')}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<FileJson size={20} />
|
||||
<span>JSON</span>
|
||||
@@ -2326,7 +2096,7 @@ export default function SnsPage() {
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('arkmejson')}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<FileJson size={20} />
|
||||
<span>ArkmeJSON</span>
|
||||
@@ -2354,7 +2124,7 @@ export default function SnsPage() {
|
||||
setExportFolder(result.filePath)
|
||||
}
|
||||
}}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
@@ -2369,9 +2139,9 @@ export default function SnsPage() {
|
||||
type="button"
|
||||
className="time-range-trigger sns-export-time-range-trigger"
|
||||
onClick={() => {
|
||||
if (!isExportLocked) setIsExportDateRangeDialogOpen(true)
|
||||
if (!isExporting) setIsExportDateRangeDialogOpen(true)
|
||||
}}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<span>{exportDateRangeLabel}</span>
|
||||
<span className="time-range-arrow">></span>
|
||||
@@ -2391,7 +2161,7 @@ export default function SnsPage() {
|
||||
type="checkbox"
|
||||
checked={exportImages}
|
||||
onChange={(e) => setExportImages(e.target.checked)}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
/>
|
||||
图片
|
||||
</label>
|
||||
@@ -2400,7 +2170,7 @@ export default function SnsPage() {
|
||||
type="checkbox"
|
||||
checked={exportLivePhotos}
|
||||
onChange={(e) => setExportLivePhotos(e.target.checked)}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
/>
|
||||
实况图
|
||||
</label>
|
||||
@@ -2409,7 +2179,7 @@ export default function SnsPage() {
|
||||
type="checkbox"
|
||||
checked={exportVideos}
|
||||
onChange={(e) => setExportVideos(e.target.checked)}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
/>
|
||||
视频
|
||||
</label>
|
||||
@@ -2424,7 +2194,7 @@ export default function SnsPage() {
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{isExportLocked && exportProgress && (
|
||||
{isExporting && exportProgress && (
|
||||
<div className="export-progress">
|
||||
<div className="export-progress-bar">
|
||||
<div
|
||||
@@ -2433,39 +2203,6 @@ export default function SnsPage() {
|
||||
/>
|
||||
</div>
|
||||
<span className="export-progress-text">{exportProgress.status}</span>
|
||||
<div className="export-progress-actions">
|
||||
{canPauseExport && (
|
||||
<button
|
||||
type="button"
|
||||
className="export-progress-btn"
|
||||
onClick={handlePauseSnsExport}
|
||||
>
|
||||
<Pause size={14} />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
{canResumeExport && (
|
||||
<button
|
||||
type="button"
|
||||
className="export-progress-btn primary"
|
||||
onClick={handleResumeSnsExport}
|
||||
>
|
||||
<Play size={14} />
|
||||
继续
|
||||
</button>
|
||||
)}
|
||||
{canCancelExport && (
|
||||
<button
|
||||
type="button"
|
||||
className="export-progress-btn danger"
|
||||
onClick={handleCancelSnsExport}
|
||||
disabled={exportTaskStatus === 'cancel_requested'}
|
||||
>
|
||||
<Square size={14} />
|
||||
{exportTaskStatus === 'cancel_requested' ? '取消中' : '取消'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2474,14 +2211,47 @@ export default function SnsPage() {
|
||||
<button
|
||||
className="export-cancel-btn"
|
||||
onClick={() => setShowExportDialog(false)}
|
||||
disabled={isExportLocked}
|
||||
disabled={isExporting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="export-start-btn"
|
||||
disabled={!canStartExport}
|
||||
onClick={handleStartSnsExport}
|
||||
onClick={async () => {
|
||||
setIsExporting(true)
|
||||
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
||||
setExportResult(null)
|
||||
|
||||
// 监听进度
|
||||
const removeProgress = window.electronAPI.sns.onExportProgress((progress: any) => {
|
||||
setExportProgress(progress)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sns.exportTimeline({
|
||||
outputDir: exportFolder,
|
||||
format: exportFormat,
|
||||
usernames: exportScope.kind === 'selected' ? exportScope.usernames : undefined,
|
||||
keyword: searchKeyword || undefined,
|
||||
exportImages,
|
||||
exportLivePhotos,
|
||||
exportVideos,
|
||||
startTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
|
||||
endTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
|
||||
})
|
||||
setExportResult(result)
|
||||
} catch (e: any) {
|
||||
setExportResult({ success: false, error: e.message || String(e) })
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
removeProgress()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExporting ? '导出中...' : '开始导出'}
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,6 @@ export const CONFIG_KEYS = {
|
||||
WINDOW_BOUNDS: 'windowBounds',
|
||||
CACHE_PATH: 'cachePath',
|
||||
LAUNCH_AT_STARTUP: 'launchAtStartup',
|
||||
SILENT_STARTUP: 'silentStartup',
|
||||
|
||||
EXPORT_PATH: 'exportPath',
|
||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||
@@ -322,17 +321,6 @@ 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)
|
||||
|
||||
27
src/types/electron.d.ts
vendored
27
src/types/electron.d.ts
vendored
@@ -35,17 +35,6 @@ export interface BackupOptions {
|
||||
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'
|
||||
@@ -58,7 +47,7 @@ export interface BackupManifest {
|
||||
options?: BackupOptions
|
||||
databases: Array<{
|
||||
id: string
|
||||
kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
|
||||
kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns'
|
||||
dbPath: string
|
||||
relativePath: string
|
||||
tables: Array<{
|
||||
@@ -81,7 +70,6 @@ export interface BackupManifest {
|
||||
targetRelativePath: string
|
||||
ext?: string
|
||||
size?: number
|
||||
datMeta?: BackupImageDatMeta
|
||||
}>
|
||||
videos?: Array<{
|
||||
kind: 'image' | 'video' | 'file'
|
||||
@@ -271,7 +259,6 @@ 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 }>
|
||||
@@ -1092,22 +1079,16 @@ export interface ElectronAPI {
|
||||
estimatedSeconds: number
|
||||
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
|
||||
}>
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => Promise<{
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
||||
success: boolean
|
||||
successCount?: number
|
||||
failCount?: number
|
||||
paused?: boolean
|
||||
stopped?: boolean
|
||||
pendingSessionIds?: string[]
|
||||
successSessionIds?: string[]
|
||||
failedSessionIds?: string[]
|
||||
failedSessionErrors?: Record<string, 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
|
||||
@@ -1180,8 +1161,7 @@ export interface ElectronAPI {
|
||||
exportVideos?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
taskId?: string
|
||||
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }>
|
||||
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; 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 }>
|
||||
@@ -1270,7 +1250,6 @@ export interface ExportOptions {
|
||||
txtColumns?: string[]
|
||||
fileNamingMode?: 'classic' | 'date-range'
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
exportWriteLayout?: 'A' | 'B' | 'C'
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
|
||||
Reference in New Issue
Block a user