diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index 15f5450..bf65d95 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -287,6 +287,12 @@ jobs: if: always() && needs.prepare.result == 'success' runs-on: ubuntu-latest steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + ref: ${{ env.TARGET_BRANCH }} + fetch-depth: 1 + - name: Update fixed dev release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 08b8556..01f7ddb 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -328,6 +328,12 @@ jobs: if: needs.prepare.outputs.should_build == 'true' && always() runs-on: ubuntu-latest steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + ref: ${{ env.TARGET_BRANCH }} + fetch-depth: 1 + - name: Update preview release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33ae8f0..fe14ef8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,6 +252,11 @@ jobs: - release-windows-arm64 steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Generate release notes with platform download links env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -345,7 +350,6 @@ jobs: updpkgsums: true assets: | resources/installer/linux/weflow.desktop - resources/installer/linux/icon.png ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} commit_username: H3CoF6 diff --git a/.gitignore b/.gitignore index da6cc34..14d720f 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,5 @@ wechat-research-site .codex weflow-web-offical /Wedecrypt -/scripts/syncwcdb.py \ No newline at end of file +/scripts/syncwcdb.py +/scripts/syncWedecrypt.py \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 57a9112..1e66ea0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -33,6 +33,7 @@ import { messagePushService } from './services/messagePushService' import { insightService } from './services/insightService' import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { bizService } from './services/bizService' +import { backupService } from './services/backupService' // 配置自动更新 autoUpdater.autoDownload = false @@ -2178,6 +2179,18 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('backup:create', async (_, payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => { + return backupService.createBackup(payload.outputPath, payload.options) + }) + + ipcMain.handle('backup:inspect', async (_, payload: { archivePath: string }) => { + return backupService.inspectBackup(payload.archivePath) + }) + + ipcMain.handle('backup:restore', async (_, payload: { archivePath: string }) => { + return backupService.restoreBackup(payload.archivePath) + }) + // 聊天相关 @@ -3996,4 +4009,3 @@ app.on('window-all-closed', () => { } }) - diff --git a/electron/preload.ts b/electron/preload.ts index 28959b7..7ba371e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -154,6 +154,17 @@ contextBridge.exposeInMainWorld('electronAPI', { }, + backup: { + create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload), + inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload), + restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload), + onProgress: (callback: (progress: any) => void) => { + const listener = (_: unknown, progress: any) => callback(progress) + ipcRenderer.on('backup:progress', listener) + return () => ipcRenderer.removeListener('backup:progress', listener) + } + }, + // 密钥获取 key: { autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts new file mode 100644 index 0000000..ba7961c --- /dev/null +++ b/electron/services/backupService.ts @@ -0,0 +1,986 @@ +import { BrowserWindow, app } from 'electron' +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' +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' +type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed' +type BackupResourceKind = 'image' | 'video' | 'file' + +export interface BackupOptions { + includeImages?: boolean + includeVideos?: boolean + includeFiles?: boolean +} + +interface BackupDbEntry { + id: string + kind: BackupDbKind + dbPath: string + relativePath: string + tables: BackupTableEntry[] +} + +interface BackupTableEntry { + name: string + snapshotPath: string + rows: number + columns: number + schemaSql?: string +} + +interface BackupResourceEntry { + kind: BackupResourceKind + id: string + md5?: string + sessionId?: string + createTime?: number + sourceFileName?: string + archivePath: string + targetRelativePath: string + ext?: string + size?: number +} + +interface BackupManifest { + version: 1 + type: 'weflow-db-snapshots' + createdAt: string + appVersion: string + source: { + wxid: string + dbRoot: string + } + databases: BackupDbEntry[] + options?: BackupOptions + resources?: { + images?: BackupResourceEntry[] + videos?: BackupResourceEntry[] + files?: BackupResourceEntry[] + } +} + +interface BackupProgress { + phase: BackupPhase + message: string + current?: number + total?: number + detail?: string +} + +function emitBackupProgress(progress: BackupProgress): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send('backup:progress', progress) + } + } +} + +function safeName(value: string): string { + return encodeURIComponent(value || 'unnamed').replace(/%/g, '_') +} + +function toArchivePath(path: string): string { + return path.split(sep).join('/') +} + +async function withTimeout(task: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | null = null + try { + return await Promise.race([ + task, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs) + }) + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +function delay(ms = 0): Promise { + return new Promise(resolveDelay => setTimeout(resolveDelay, ms)) +} + +function createThrottledProgressEmitter(minIntervalMs = 120): (progress: BackupProgress, force?: boolean) => void { + let lastEmitAt = 0 + return (progress: BackupProgress, force = false) => { + const now = Date.now() + if (!force && now - lastEmitAt < minIntervalMs) return + lastEmitAt = now + emitBackupProgress(progress) + } +} + +async function runWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise +): Promise { + let nextIndex = 0 + const workerCount = Math.max(1, Math.min(concurrency, items.length)) + await Promise.all(Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex + nextIndex += 1 + if (index >= items.length) return + await worker(items[index], index) + if (index % 50 === 0) await delay() + } + })) +} + +function hasResourceOptions(options: BackupOptions): boolean { + return options.includeImages === true || options.includeVideos === true || options.includeFiles === true +} + +export class BackupService { + private configService = new ConfigService() + + private buildWxidCandidates(wxid: string): string[] { + const wxidCandidates = Array.from(new Set([ + String(wxid || '').trim(), + this.cleanAccountDirName(wxid) + ].filter(Boolean))) + return wxidCandidates + } + + private isCurrentAccountDir(accountDir: string, wxidCandidates: string[]): boolean { + const accountName = basename(accountDir).toLowerCase() + return wxidCandidates + .map(item => item.toLowerCase()) + .some(wxid => accountName === wxid || accountName.startsWith(`${wxid}_`)) + } + + private normalizeExistingPath(inputPath: string): string { + const expanded = expandHomePath(String(inputPath || '').trim()).replace(/[\\/]+$/, '') + if (!expanded) return expanded + try { + if (existsSync(expanded) && statSync(expanded).isFile()) { + return dirname(expanded) + } + } catch {} + return expanded + } + + private resolveAncestorDbStorage(normalized: string, wxidCandidates: string[]): string | null { + let current = normalized + for (let i = 0; i < 8; i += 1) { + if (!current) break + if (basename(current).toLowerCase() === 'db_storage') { + const accountDir = dirname(current) + if (this.isCurrentAccountDir(accountDir, wxidCandidates) && existsSync(current)) { + return current + } + } + const parent = dirname(current) + if (!parent || parent === current) break + current = parent + } + return null + } + + private resolveCurrentAccountDbStorageFromRoot(rootPath: string, wxidCandidates: string[]): string | null { + if (!rootPath || !existsSync(rootPath)) return null + + for (const candidateWxid of wxidCandidates) { + const viaWxid = join(rootPath, candidateWxid, 'db_storage') + if (existsSync(viaWxid)) return viaWxid + } + + try { + const entries = readdirSync(rootPath) + const loweredWxids = wxidCandidates.map(item => item.toLowerCase()) + for (const entry of entries) { + const entryPath = join(rootPath, entry) + try { + if (!statSync(entryPath).isDirectory()) continue + } catch { + continue + } + const lowerEntry = entry.toLowerCase() + if (!loweredWxids.some(id => lowerEntry === id || lowerEntry.startsWith(`${id}_`))) continue + const candidate = join(entryPath, 'db_storage') + if (existsSync(candidate)) return candidate + } + } catch {} + + return null + } + + private resolveDbStoragePath(dbPath: string, wxid: string): string | null { + const normalized = this.normalizeExistingPath(dbPath) + if (!normalized) return null + + const wxidCandidates = this.buildWxidCandidates(wxid) + const ancestor = this.resolveAncestorDbStorage(normalized, wxidCandidates) + if (ancestor) return ancestor + + const direct = join(normalized, 'db_storage') + if (existsSync(direct) && this.isCurrentAccountDir(normalized, wxidCandidates)) return direct + + const roots = Array.from(new Set([ + normalized, + join(normalized, 'WeChat Files'), + join(normalized, 'xwechat_files') + ])) + for (const root of roots) { + const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, wxidCandidates) + if (dbStorage) return dbStorage + } + + return null + } + + private resolveAccountDir(dbPath: string, wxid: string): string | null { + const dbStorage = this.resolveDbStoragePath(dbPath, wxid) + return dbStorage ? dirname(dbStorage) : null + } + + private cleanAccountDirName(wxid: string): string { + const trimmed = String(wxid || '').trim() + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + private 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 { + const dir = join(root, rel) + const files: string[] = [] + for (const entry of readdirSync(dir)) { + const entryRel = rel ? join(rel, entry) : entry + const entryPath = join(root, entryRel) + try { + const stat = statSync(entryPath) + if (stat.isDirectory()) { + files.push(...await this.listFilesForArchive(root, entryRel, state)) + } else if (stat.isFile()) { + files.push(toArchivePath(entryRel)) + } + state.visited += 1 + if (state.visited % 200 === 0) await delay() + } catch {} + } + return files + } + + private resolveExtractedPath(extractDir: string, archivePath: string): string | null { + const normalized = String(archivePath || '').replace(/\\/g, '/') + if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null + const root = resolve(extractDir) + const target = resolve(join(extractDir, normalized)) + if (target !== root && !target.startsWith(`${root}${sep}`)) return null + return target + } + + private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null { + 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)) + if (target !== root && !target.startsWith(`${root}${sep}`)) return null + return target + } + + private isSafeAccountRelativePath(accountDir: string, filePath: string): string | null { + const rel = toArchivePath(relative(accountDir, filePath)) + if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null + return rel + } + + private async listFilesUnderDir(root: string, state = { visited: 0 }): Promise { + const files: string[] = [] + if (!existsSync(root)) return files + try { + for (const entry of readdirSync(root)) { + const fullPath = join(root, entry) + let stat + try { + stat = statSync(fullPath) + } catch { + continue + } + if (stat.isDirectory()) { + files.push(...await this.listFilesUnderDir(fullPath, state)) + } else if (stat.isFile()) { + files.push(fullPath) + } + state.visited += 1 + if (state.visited % 300 === 0) await delay() + } + } catch {} + return files + } + + private async stagePlainResource(sourcePath: string, outputPath: string): Promise { + mkdirSync(dirname(outputPath), { recursive: true }) + try { + await link(sourcePath, outputPath) + } catch { + await copyFile(sourcePath, outputPath) + } + } + + private async listChatImageDatFiles(accountDir: string): Promise { + const attachRoot = join(accountDir, 'msg', 'attach') + const result: string[] = [] + if (!existsSync(attachRoot)) return result + + const scanImgDir = async (imgDir: string): Promise => { + let entries: string[] = [] + try { + entries = readdirSync(imgDir) + } catch { + return + } + for (const entry of entries) { + const fullPath = join(imgDir, entry) + let stat + try { + stat = statSync(fullPath) + } catch { + continue + } + if (stat.isFile() && entry.toLowerCase().endsWith('.dat')) { + result.push(fullPath) + } else if (stat.isDirectory()) { + let nestedEntries: string[] = [] + try { + nestedEntries = readdirSync(fullPath) + } catch { + continue + } + for (const nestedEntry of nestedEntries) { + const nestedPath = join(fullPath, nestedEntry) + try { + if (statSync(nestedPath).isFile() && nestedEntry.toLowerCase().endsWith('.dat')) { + result.push(nestedPath) + } + } catch {} + } + } + if (result.length > 0 && result.length % 500 === 0) await delay() + } + } + + const walk = async (dir: string): Promise => { + let entries: Array<{ name: string; isDirectory: () => boolean }> = [] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + const child = join(dir, entry.name) + if (entry.name.toLowerCase() === 'img') { + await scanImgDir(child) + } else { + await walk(child) + } + if (result.length > 0 && result.length % 500 === 0) await delay() + } + } + + await walk(attachRoot) + return Array.from(new Set(result)) + } + + private async ensureConnected(wxidOverride?: string): Promise<{ success: boolean; wxid?: string; dbPath?: string; dbStorage?: string; error?: string }> { + const configuredWxid = String(this.configService.get('myWxid') || '').trim() + const wxid = String(wxidOverride || configuredWxid || '').trim() + const dbPath = String(this.configService.get('dbPath') || '').trim() + const decryptKey = String(this.configService.get('decryptKey') || '').trim() + if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' } + if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' } + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` } + const dbStorage = join(accountDir, 'db_storage') + if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' } + + const accountDirName = basename(accountDir) + const opened = await withTimeout( + wcdbService.open(dbPath, decryptKey, accountDirName), + 15000, + '连接目标账号数据库超时,请检查数据库路径、密钥是否正确' + ) + if (!opened) { + const detail = await wcdbService.getLastInitError().catch(() => null) + return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` } + } + + return { success: true, wxid: accountDirName, dbPath, dbStorage } + } + + private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string { + if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns') return kind + return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}` + } + + private toDbRelativePath(dbStorage: string, dbPath: string): string { + const rel = toArchivePath(relative(dbStorage, dbPath)) + if (!rel || rel.startsWith('..') || rel.startsWith('/')) return basename(dbPath) + return rel + } + + private resolveTargetDbPath(dbStorage: string, relativePath: string): string | null { + const normalized = String(relativePath || '').replace(/\\/g, '/') + if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null + const root = resolve(dbStorage) + const target = resolve(join(dbStorage, normalized)) + if (target !== root && !target.startsWith(`${root}${sep}`)) return null + return target + } + + private defaultRelativeDbPath(kind: BackupDbKind): string | null { + if (kind === 'session') return 'session/session.db' + if (kind === 'contact') return 'contact/contact.db' + if (kind === 'emoticon') return 'emoticon/emoticon.db' + if (kind === 'sns') return 'sns/sns.db' + return null + } + + private resolveRestoreTargetDbPath(dbStorage: string, db: BackupDbEntry): string | null { + const normalized = String(db.relativePath || '').replace(/\\/g, '/') + const legacyFixedPath = this.defaultRelativeDbPath(db.kind) + if (legacyFixedPath && (!normalized.includes('/') || !normalized.toLowerCase().endsWith('.db'))) { + return this.resolveTargetDbPath(dbStorage, legacyFixedPath) + } + return this.resolveTargetDbPath(dbStorage, db.relativePath) + } + + private findFirstExisting(paths: string[]): string { + for (const path of paths) { + try { + if (existsSync(path) && statSync(path).isFile()) return path + } catch {} + } + return '' + } + + private resolveKnownDbPath(kind: BackupDbKind, dbStorage: string): string { + if (kind === 'session') { + return this.findFirstExisting([ + join(dbStorage, 'session', 'session.db'), + join(dbStorage, 'Session', 'session.db'), + join(dbStorage, 'session.db') + ]) + } + if (kind === 'contact') { + return this.findFirstExisting([ + join(dbStorage, 'Contact', 'contact.db'), + join(dbStorage, 'Contact', 'Contact.db'), + join(dbStorage, 'contact', 'contact.db'), + join(dbStorage, 'session', 'contact.db') + ]) + } + if (kind === 'emoticon') { + return this.findFirstExisting([ + join(dbStorage, 'emoticon', 'emoticon.db'), + join(dbStorage, 'emotion', 'emoticon.db') + ]) + } + if (kind === 'sns') { + return this.findFirstExisting([ + join(dbStorage, 'sns', 'sns.db'), + join(dirname(dbStorage), 'sns', 'sns.db') + ]) + } + return '' + } + + private async collectDatabases(dbStorage: string): Promise>> { + const result: Array> = [] + for (const kind of ['session', 'contact', 'emoticon', 'sns'] as const) { + const dbPath = this.resolveKnownDbPath(kind, dbStorage) + result.push({ + id: kind, + kind, + dbPath, + relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind + }) + } + + const messageDbs = await wcdbService.listMessageDbs() + if (messageDbs.success && Array.isArray(messageDbs.data)) { + messageDbs.data.forEach((dbPath, index) => { + result.push({ + id: this.buildDbId('message', index, dbPath), + kind: 'message', + dbPath, + relativePath: this.toDbRelativePath(dbStorage, dbPath) + }) + }) + } + + const mediaDbs = await wcdbService.listMediaDbs() + if (mediaDbs.success && Array.isArray(mediaDbs.data)) { + mediaDbs.data.forEach((dbPath, index) => { + result.push({ + id: this.buildDbId('media', index, dbPath), + kind: 'media', + dbPath, + relativePath: this.toDbRelativePath(dbStorage, dbPath) + }) + }) + } + + return result + } + + private async collectImageResources( + connected: { wxid: string; dbStorage: string }, + stagingDir: string, + manifest: BackupManifest + ): Promise { + const accountDir = dirname(connected.dbStorage) + const 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[] = [] + const emitImageProgress = createThrottledProgressEmitter(160) + for (let index = 0; index < imagePaths.length; index += 1) { + const sourcePath = imagePaths[index] + const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath) + if (!relativeTarget) continue + emitImageProgress({ + phase: 'exporting', + message: '正在解密图片资源', + current: index + 1, + total: imagePaths.length, + detail: relativeTarget + }) + const 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) + mkdirSync(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, decrypted.data) + const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase() + resources.push({ + kind: 'image', + id: relativeTarget, + md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined, + sourceFileName: basename(sourcePath), + archivePath, + targetRelativePath: relativeTarget, + ext: decrypted.ext || undefined, + size: decrypted.data.length + }) + if (index % 20 === 0) await delay() + } + + if (resources.length > 0) { + manifest.resources = { ...(manifest.resources || {}), images: resources } + } + } + + private async collectPlainResources( + connected: { dbStorage: string }, + stagingDir: string, + manifest: BackupManifest, + kind: 'video' | 'file' + ): Promise { + const accountDir = dirname(connected.dbStorage) + const roots = kind === 'video' + ? [ + join(accountDir, 'msg', 'video'), + join(accountDir, 'FileStorage', 'Video') + ] + : [ + join(accountDir, 'FileStorage', 'File'), + join(accountDir, 'msg', 'file') + ] + const listed = await Promise.all(roots.map(root => this.listFilesUnderDir(root))) + const uniqueFiles = Array.from(new Set(listed.flat())) + if (uniqueFiles.length === 0) return + + const resources: BackupResourceEntry[] = [] + const bucket = kind === 'video' ? 'videos' : 'files' + const emitResourceProgress = createThrottledProgressEmitter(180) + await runWithConcurrency(uniqueFiles, 4, async (sourcePath, index) => { + emitResourceProgress({ + phase: 'exporting', + message: kind === 'video' ? '正在归档视频资源' : '正在归档文件资源', + current: index + 1, + total: uniqueFiles.length, + detail: basename(sourcePath) + }) + const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath) + if (!relativeTarget) return + const archivePath = toArchivePath(join('resources', bucket, relativeTarget)) + const outputPath = join(stagingDir, archivePath) + await this.stagePlainResource(sourcePath, outputPath) + let size = 0 + try { size = statSync(sourcePath).size } catch {} + const entry: BackupResourceEntry = { + kind, + id: relativeTarget, + sourceFileName: basename(sourcePath), + archivePath, + targetRelativePath: relativeTarget, + size + } + resources.push(entry) + }) + + if (resources.length > 0) { + manifest.resources = { + ...(manifest.resources || {}), + [bucket]: resources + } + } + } + + async createBackup(outputPath: string, options: BackupOptions = {}): Promise<{ success: boolean; filePath?: string; manifest?: BackupManifest; error?: string }> { + let stagingDir = '' + try { + emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' }) + const connected = await this.ensureConnected() + if (!connected.success || !connected.wxid || !connected.dbPath || !connected.dbStorage) { + return { success: false, error: connected.error || '数据库未连接' } + } + + stagingDir = await mkdtemp(join(tmpdir(), 'weflow-backup-')) + const snapshotsDir = join(stagingDir, 'snapshots') + mkdirSync(snapshotsDir, { recursive: true }) + + const dbs = await this.collectDatabases(connected.dbStorage) + const manifest: BackupManifest = { + version: 1, + type: 'weflow-db-snapshots', + createdAt: new Date().toISOString(), + appVersion: app.getVersion(), + source: { + wxid: connected.wxid, + dbRoot: connected.dbPath + }, + databases: [], + options: { + includeImages: options.includeImages === true, + includeVideos: options.includeVideos === true, + includeFiles: options.includeFiles === true + } + } + + const tableJobs: Array<{ db: Omit; table: string; schemaSql: string; snapshotPath: string; outputPath: string }> = [] + for (let index = 0; index < dbs.length; index += 1) { + const db = dbs[index] + emitBackupProgress({ + phase: 'scanning', + message: '正在扫描数据库和表', + current: index + 1, + total: dbs.length, + detail: `${db.kind}:${db.relativePath || db.dbPath || db.id}` + }) + const tablesResult = await wcdbService.listTables(db.kind, db.dbPath) + if (!tablesResult.success || !Array.isArray(tablesResult.tables) || tablesResult.tables.length === 0) continue + const dbDir = join(snapshotsDir, db.id) + mkdirSync(dbDir, { recursive: true }) + const entry: BackupDbEntry = { ...db, tables: [] } + manifest.databases.push(entry) + for (const table of tablesResult.tables) { + const schemaResult = await wcdbService.getTableSchema(db.kind, db.dbPath, table) + if (!schemaResult.success || !schemaResult.schema) continue + const snapshotPath = toArchivePath(join('snapshots', db.id, `${safeName(table)}.wfsnap`)) + tableJobs.push({ + db, + table, + schemaSql: schemaResult.schema, + snapshotPath, + outputPath: join(stagingDir, snapshotPath) + }) + } + } + + let current = 0 + for (const job of tableJobs) { + current++ + emitBackupProgress({ + phase: 'exporting', + message: '正在导出数据库快照', + current, + total: tableJobs.length, + detail: `${job.db.kind}:${job.table}` + }) + const exported = await wcdbService.exportTableSnapshot(job.db.kind, job.db.dbPath, job.table, job.outputPath) + if (!exported.success) { + throw new Error(`${job.db.kind}:${job.table} 导出失败:${exported.error || 'unknown'}`) + } + const dbEntry = manifest.databases.find(item => item.id === job.db.id) + dbEntry?.tables.push({ + name: job.table, + snapshotPath: job.snapshotPath, + rows: exported.rows || 0, + columns: exported.columns || 0, + schemaSql: job.schemaSql + }) + } + + if (options.includeImages === true) { + await this.collectImageResources( + { wxid: connected.wxid, dbStorage: connected.dbStorage }, + stagingDir, + manifest + ) + } + if (options.includeVideos === true) { + await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'video') + } + if (options.includeFiles === true) { + await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'file') + } + + await writeFile(join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8') + mkdirSync(dirname(outputPath), { recursive: true }) + const archiveFiles = await this.listFilesForArchive(stagingDir) + const shouldCompress = !hasResourceOptions(options) + let packed = 0 + const emitPackingProgress = createThrottledProgressEmitter(150) + emitBackupProgress({ phase: 'packing', message: '正在生成备份包', current: 0, total: archiveFiles.length }) + await tar.c({ + gzip: shouldCompress ? { level: 1 } : false, + cwd: stagingDir, + file: outputPath, + portable: true, + noMtime: true, + sync: false, + onWriteEntry: (entry: any) => { + packed += 1 + emitPackingProgress({ + phase: 'packing', + message: '正在写入备份包', + current: Math.min(packed, archiveFiles.length), + total: archiveFiles.length, + detail: String(entry?.path || entry || '') + }) + } + } as any, archiveFiles) + emitBackupProgress({ + phase: 'packing', + message: '正在写入备份包', + current: archiveFiles.length, + total: archiveFiles.length + }) + emitBackupProgress({ phase: 'done', message: '备份完成', current: tableJobs.length, total: tableJobs.length }) + return { success: true, filePath: outputPath, manifest } + } catch (e) { + const error = e instanceof Error ? e.message : String(e) + emitBackupProgress({ phase: 'failed', message: error }) + return { success: false, error } + } finally { + if (stagingDir) { + try { rmSync(stagingDir, { recursive: true, force: true }) } catch {} + } + } + } + + async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> { + let extractDir = '' + try { + emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' }) + extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-inspect-')) + await tar.x({ + file: archivePath, + cwd: extractDir, + filter: (entryPath: string) => entryPath.replace(/\\/g, '/') === 'manifest.json' + } as any) + const manifestPath = join(extractDir, 'manifest.json') + if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' } + const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest + if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) { + return { success: false, error: '不支持的备份包格式' } + } + return { success: true, manifest } + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) } + } finally { + if (extractDir) { + try { rmSync(extractDir, { recursive: true, force: true }) } catch {} + } + } + } + + async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> { + let extractDir = '' + try { + 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 + if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) { + return { success: false, error: '不支持的备份包格式' } + } + const targetWxid = String(manifest.source?.wxid || '').trim() + if (!targetWxid) return { success: false, error: '备份包缺少来源账号 wxid,无法定位目标账号目录' } + + emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetWxid }) + const connected = await this.ensureConnected(targetWxid) + if (!connected.success || !connected.dbStorage) return { success: false, error: connected.error || '数据库未连接' } + + const tableJobs = manifest.databases.flatMap(db => db.tables.map(table => ({ db, table }))) + const imageJobs = manifest.resources?.images || [] + const plainResourceJobs = [ + ...(manifest.resources?.videos || []), + ...(manifest.resources?.files || []) + ] + const totalRestoreJobs = tableJobs.length + imageJobs.length + plainResourceJobs.length + let inserted = 0 + let ignored = 0 + let skipped = 0 + let current = 0 + for (const job of tableJobs) { + current++ + const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db) + if (targetDbPath === null) { + skipped++ + continue + } + if (!job.table.schemaSql) { + skipped++ + continue + } + + emitBackupProgress({ + phase: 'restoring', + message: '正在通过 WCDB 写入数据库', + current, + total: totalRestoreJobs, + detail: `${job.db.kind}:${job.table.name}` + }) + const inputPath = this.resolveExtractedPath(extractDir, job.table.snapshotPath) + if (!inputPath || !existsSync(inputPath)) { + skipped++ + continue + } + mkdirSync(dirname(targetDbPath), { recursive: true }) + const restored = await wcdbService.importTableSnapshotWithSchema( + job.db.kind, + targetDbPath, + job.table.name, + inputPath, + job.table.schemaSql + ) + if (!restored.success) { + skipped++ + continue + } + inserted += restored.inserted || 0 + ignored += restored.ignored || 0 + if (current % 4 === 0) await delay() + } + + 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) { + const error = e instanceof Error ? e.message : String(e) + emitBackupProgress({ phase: 'failed', message: error }) + return { success: false, error } + } finally { + if (extractDir) { + try { rmSync(extractDir, { recursive: true, force: true }) } catch {} + } + } + } +} + +export const backupService = new BackupService() diff --git a/electron/services/nativeImageDecrypt.ts b/electron/services/nativeImageDecrypt.ts index bcaacb7..8400cc7 100644 --- a/electron/services/nativeImageDecrypt.ts +++ b/electron/services/nativeImageDecrypt.ts @@ -10,6 +10,7 @@ type NativeDecryptResult = { type NativeAddon = { decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult + encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string) => Buffer } let cachedAddon: NativeAddon | null | undefined @@ -108,3 +109,19 @@ export function decryptDatViaNative( return null } } + +export function encryptDatViaNative( + inputPath: string, + xorKey: number, + aesKey?: string +): Buffer | null { + const addon = loadAddon() + if (!addon || typeof addon.encryptDatNative !== 'function') return null + + try { + const result = addon.encryptDatNative(inputPath, xorKey, aesKey) + return Buffer.isBuffer(result) ? result : null + } catch { + return null + } +} diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index af797f7..489991b 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -91,6 +91,11 @@ export class WcdbCore { private wcdbGetSnsUsernames: any = null private wcdbGetSnsExportStats: any = null private wcdbGetMessageTableColumns: any = null + private wcdbListTables: any = null + private wcdbGetTableSchema: any = null + private wcdbExportTableSnapshot: any = null + private wcdbImportTableSnapshot: any = null + private wcdbImportTableSnapshotWithSchema: any = null private wcdbGetMessageTableTimeRange: any = null private wcdbResolveImageHardlink: any = null private wcdbResolveImageHardlinkBatch: any = null @@ -1090,6 +1095,31 @@ export class WcdbCore { } catch { this.wcdbGetMessageTableColumns = null } + try { + this.wcdbListTables = this.lib.func('int32 wcdb_list_tables(int64 handle, const char* kind, const char* dbPath, _Out_ void** outJson)') + } catch { + this.wcdbListTables = null + } + try { + this.wcdbGetTableSchema = this.lib.func('int32 wcdb_get_table_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, _Out_ void** outJson)') + } catch { + this.wcdbGetTableSchema = null + } + try { + this.wcdbExportTableSnapshot = this.lib.func('int32 wcdb_export_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* outputPath, _Out_ void** outJson)') + } catch { + this.wcdbExportTableSnapshot = null + } + try { + this.wcdbImportTableSnapshot = this.lib.func('int32 wcdb_import_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, _Out_ void** outJson)') + } catch { + this.wcdbImportTableSnapshot = null + } + try { + this.wcdbImportTableSnapshotWithSchema = this.lib.func('int32 wcdb_import_table_snapshot_with_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, const char* createTableSql, _Out_ void** outJson)') + } catch { + this.wcdbImportTableSnapshotWithSchema = null + } try { this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') } catch { @@ -2902,6 +2932,96 @@ export class WcdbCore { } } + async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbListTables) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbListTables(this.handle, kind, dbPath || '', outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取表列表失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析表列表失败' } + const tables = JSON.parse(jsonStr) + return { success: true, tables: Array.isArray(tables) ? tables.map((c: any) => String(c || '')).filter(Boolean) : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetTableSchema) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetTableSchema(this.handle, kind, dbPath || '', tableName, outPtr) + const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' + const data = jsonStr ? JSON.parse(jsonStr) : {} + if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `获取表结构失败: ${result}` } + return { success: true, schema: String(data?.schema || '') } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbExportTableSnapshot) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbExportTableSnapshot(this.handle, kind, dbPath || '', tableName, outputPath, outPtr) + const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' + const data = jsonStr ? JSON.parse(jsonStr) : {} + if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导出表快照失败: ${result}` } + return { success: true, rows: Number(data?.rows || 0), columns: Number(data?.columns || 0) } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbImportTableSnapshot) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbImportTableSnapshot(this.handle, kind, dbPath || '', tableName, inputPath, outPtr) + const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' + const data = jsonStr ? JSON.parse(jsonStr) : {} + if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` } + return { + success: true, + rows: Number(data?.rows || 0), + inserted: Number(data?.inserted || 0), + ignored: Number(data?.ignored || 0), + malformed: Number(data?.malformed || 0), + columns: Number(data?.columns || 0) + } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbImportTableSnapshotWithSchema) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbImportTableSnapshotWithSchema(this.handle, kind, dbPath || '', tableName, inputPath, createTableSql || '', outPtr) + const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' + const data = jsonStr ? JSON.parse(jsonStr) : {} + if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` } + return { + success: true, + rows: Number(data?.rows || 0), + inserted: Number(data?.inserted || 0), + ignored: Number(data?.ignored || 0), + malformed: Number(data?.malformed || 0), + columns: Number(data?.columns || 0) + } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 2f1957f..8354141 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -366,6 +366,26 @@ export class WcdbService { return this.callWorker('getMessageTableColumns', { dbPath, tableName }) } + async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> { + return this.callWorker('listTables', { kind, dbPath }) + } + + async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> { + return this.callWorker('getTableSchema', { kind, dbPath, tableName }) + } + + async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> { + return this.callWorker('exportTableSnapshot', { kind, dbPath, tableName, outputPath }) + } + + async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { + return this.callWorker('importTableSnapshot', { kind, dbPath, tableName, inputPath }) + } + + async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { + return this.callWorker('importTableSnapshotWithSchema', { kind, dbPath, tableName, inputPath, createTableSql }) + } + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { return this.callWorker('getMessageTableTimeRange', { dbPath, tableName }) } diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 2992d01..d8e3ed3 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -116,6 +116,21 @@ if (parentPort) { case 'getMessageTableColumns': result = await core.getMessageTableColumns(payload.dbPath, payload.tableName) break + case 'listTables': + result = await core.listTables(payload.kind, payload.dbPath) + break + case 'getTableSchema': + result = await core.getTableSchema(payload.kind, payload.dbPath, payload.tableName) + break + case 'exportTableSnapshot': + result = await core.exportTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.outputPath) + break + case 'importTableSnapshot': + result = await core.importTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.inputPath) + break + case 'importTableSnapshotWithSchema': + result = await core.importTableSnapshotWithSchema(payload.kind, payload.dbPath, payload.tableName, payload.inputPath, payload.createTableSql) + break case 'getMessageTableTimeRange': result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName) break diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index e367305..504c2b5 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index 88c9fd4..72f2368 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index a0abeb1..198eedf 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 8d02f6c..8eb70c7 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node index f0c1837..c156dce 100644 Binary files a/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node and b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node differ diff --git a/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node index 02a4881..0b28aab 100644 Binary files a/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node and b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node differ diff --git a/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node index eebe65e..098b0d1 100644 Binary files a/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node and b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node differ diff --git a/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node index bea4af9..515acfb 100644 Binary files a/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node and b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node differ diff --git a/src/App.tsx b/src/App.tsx index bb5d7f4..5834978 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import ResourcesPage from './pages/ResourcesPage' import ChatHistoryPage from './pages/ChatHistoryPage' import NotificationWindow from './pages/NotificationWindow' import AccountManagementPage from './pages/AccountManagementPage' +import BackupPage from './pages/BackupPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore' @@ -705,6 +706,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4b9a0e7..6609c20 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users } from 'lucide-react' +import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore } from 'lucide-react' import { useAppStore } from '../stores/appStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' @@ -412,6 +412,15 @@ function Sidebar({ collapsed }: SidebarProps) { )} + + + 数据库备份 + + diff --git a/src/pages/BackupPage.scss b/src/pages/BackupPage.scss new file mode 100644 index 0000000..f05e82e --- /dev/null +++ b/src/pages/BackupPage.scss @@ -0,0 +1,298 @@ +.backup-page { + height: 100%; + overflow: auto; + padding: 24px; + color: var(--text-primary); + background: var(--bg-primary); +} + +.backup-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; + + h1 { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: 0; + } + + p { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 14px; + } +} + +.backup-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.resource-options { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin: -8px 0 18px; + + label { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 36px; + padding: 8px 10px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + } + + input { + margin: 0; + } + + svg { + color: var(--primary); + } +} + +.primary-btn, +.secondary-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 9px 12px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.primary-btn { + background: var(--primary); + color: var(--on-primary); + border-color: var(--primary); +} + +.secondary-btn { + background: var(--bg-secondary); + color: var(--text-primary); + + &:not(:disabled):hover { + background: var(--bg-tertiary); + } +} + +.backup-status-band { + min-height: 88px; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 18px; + padding: 16px 0; +} + +.status-icon { + width: 42px; + height: 42px; + border-radius: 8px; + background: var(--bg-secondary); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.status-body { + min-width: 0; + flex: 1; +} + +.status-title { + font-size: 15px; + font-weight: 700; + margin-bottom: 4px; +} + +.status-detail { + color: var(--text-secondary); + font-size: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.progress-track { + margin-top: 12px; + height: 6px; + background: var(--bg-tertiary); + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--primary); + transition: width 0.2s ease; +} + +.backup-summary, +.restore-result { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.summary-item, +.restore-result > div { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + padding: 14px; + min-height: 74px; + display: flex; + flex-direction: column; + gap: 6px; + + svg { + color: var(--primary); + } + + span { + color: var(--text-secondary); + font-size: 12px; + } + + strong { + color: var(--text-primary); + font-size: 20px; + line-height: 1.1; + } +} + +.backup-detail { + border-top: 1px solid var(--border-color); + padding-top: 18px; +} + +.detail-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 18px; + } + + span { + color: var(--text-secondary); + font-size: 12px; + } +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; + + div { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + background: var(--bg-secondary); + min-width: 0; + } + + span { + display: block; + color: var(--text-secondary); + font-size: 12px; + margin-bottom: 5px; + } + + strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + } +} + +.db-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.db-row { + display: grid; + grid-template-columns: 110px 80px minmax(0, 1fr); + gap: 10px; + align-items: center; + border-bottom: 1px solid var(--border-color); + padding: 9px 0; + font-size: 13px; + + span { + color: var(--primary); + font-weight: 700; + } + + strong { + font-weight: 600; + } + + em { + color: var(--text-secondary); + font-style: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +@media (max-width: 760px) { + .backup-header { + flex-direction: column; + } + + .backup-actions { + width: 100%; + justify-content: flex-start; + } + + .backup-summary, + .restore-result, + .detail-grid { + grid-template-columns: 1fr; + } + + .db-row { + grid-template-columns: 82px 64px minmax(0, 1fr); + } +} diff --git a/src/pages/BackupPage.tsx b/src/pages/BackupPage.tsx new file mode 100644 index 0000000..2873329 --- /dev/null +++ b/src/pages/BackupPage.tsx @@ -0,0 +1,305 @@ +import { useEffect, useMemo, useState } from 'react' +import { ArchiveRestore, Database, Download, File, FileArchive, Image, Upload, Video } from 'lucide-react' +import './BackupPage.scss' + +type BackupManifest = NonNullable>['manifest']> +type BackupProgress = Parameters[0]>[0] + +function formatDate(value?: string): string { + if (!value) return '-' + try { + return new Date(value).toLocaleString() + } catch { + return value + } +} + +function summarizeManifest(manifest?: BackupManifest | null) { + if (!manifest) return { dbCount: 0, tableCount: 0, rowCount: 0, resourceCount: 0 } + let tableCount = 0 + let rowCount = 0 + for (const db of manifest.databases || []) { + tableCount += db.tables?.length || 0 + rowCount += (db.tables || []).reduce((sum, table) => sum + (table.rows || 0), 0) + } + const resourceCount = + (manifest.resources?.images?.length || 0) + + (manifest.resources?.videos?.length || 0) + + (manifest.resources?.files?.length || 0) + return { dbCount: manifest.databases?.length || 0, tableCount, rowCount, resourceCount } +} + +function BackupPage() { + const [progress, setProgress] = useState(null) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + const [selectedArchive, setSelectedArchive] = useState('') + const [manifest, setManifest] = useState(null) + const [restoreSummary, setRestoreSummary] = useState<{ inserted: number; ignored: number; skipped: number } | null>(null) + const [resourceOptions, setResourceOptions] = useState({ + includeImages: false, + includeVideos: false, + includeFiles: false + }) + + useEffect(() => { + return window.electronAPI.backup.onProgress(setProgress) + }, []) + + const summary = useMemo(() => summarizeManifest(manifest), [manifest]) + const percent = progress?.total && progress.total > 0 + ? Math.min(100, Math.round(((progress.current || 0) / progress.total) * 100)) + : (busy ? 8 : 0) + + const handleCreateBackup = async () => { + if (busy) return + setBusy(true) + setProgress(null) + setMessage('') + setRestoreSummary(null) + try { + const hasResources = resourceOptions.includeImages || resourceOptions.includeVideos || resourceOptions.includeFiles + const extension = hasResources ? 'tar' : 'tar.gz' + const defaultPath = `weflow-db-backup-${new Date().toISOString().slice(0, 10)}.${extension}` + const result = await window.electronAPI.dialog.saveFile({ + title: '保存数据库备份', + defaultPath, + filters: [{ name: 'WeFlow 数据库备份', extensions: hasResources ? ['tar'] : ['gz'] }] + }) + if (result.canceled || !result.filePath) { + setMessage('已取消') + return + } + const created = await window.electronAPI.backup.create({ + outputPath: result.filePath, + options: resourceOptions + }) + if (!created.success) { + setProgress(null) + setMessage(created.error || '备份失败') + return + } + setSelectedArchive(created.filePath || result.filePath) + setManifest(created.manifest || null) + setMessage('备份完成') + } catch (error) { + setProgress(null) + setMessage(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const handlePickArchive = async () => { + if (busy) return + setBusy(true) + setProgress(null) + setMessage('') + setRestoreSummary(null) + try { + const result = await window.electronAPI.dialog.openFile({ + title: '选择数据库备份', + properties: ['openFile'], + filters: [ + { name: 'WeFlow 数据库备份', extensions: ['tar', 'gz', 'tgz'] }, + { name: '所有文件', extensions: ['*'] } + ] + }) + if (result.canceled || !result.filePaths?.[0]) { + setMessage('已取消') + return + } + const archivePath = result.filePaths[0] + const inspected = await window.electronAPI.backup.inspect({ archivePath }) + if (!inspected.success) { + setProgress(null) + setMessage(inspected.error || '读取备份失败') + return + } + setSelectedArchive(archivePath) + setManifest(inspected.manifest || null) + setMessage('备份包已读取') + } catch (error) { + setProgress(null) + setMessage(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const handleRestore = async () => { + if (busy || !selectedArchive) return + setBusy(true) + setProgress(null) + setMessage('') + setRestoreSummary(null) + try { + const restored = await window.electronAPI.backup.restore({ archivePath: selectedArchive }) + if (!restored.success) { + setProgress(null) + setMessage(restored.error || '载入失败') + return + } + setRestoreSummary({ + inserted: restored.inserted || 0, + ignored: restored.ignored || 0, + skipped: restored.skipped || 0 + }) + setMessage('载入完成') + } catch (error) { + setProgress(null) + setMessage(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+

数据库备份

+

Snapshots 增量备份与载入

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

备份信息

+ {formatDate(manifest.createdAt)} +
+
+
+ 来源账号 + {manifest.source.wxid || '-'} +
+
+ 版本 + {manifest.appVersion || '-'} +
+
+ 资源 + + 图片 {manifest.resources?.images?.length || 0} / 视频 {manifest.resources?.videos?.length || 0} / 文件 {manifest.resources?.files?.length || 0} + +
+
+
+ {manifest.databases.map(db => ( +
+ {db.kind} + {db.tables.length} 表 + {db.relativePath} +
+ ))} +
+
+ )} + + {restoreSummary && ( +
+
+ 新增 + {restoreSummary.inserted.toLocaleString()} +
+
+ 已存在 + {restoreSummary.ignored.toLocaleString()} +
+
+ 跳过 + {restoreSummary.skipped.toLocaleString()} +
+
+ )} +
+ ) +} + +export default BackupPage diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ce86088..38445eb 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -21,6 +21,76 @@ export interface SocialSaveWeiboCookieResult { error?: string } +export interface BackupProgress { + phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed' + message: string + current?: number + total?: number + detail?: string +} + +export interface BackupOptions { + includeImages?: boolean + includeVideos?: boolean + includeFiles?: boolean +} + +export interface BackupManifest { + version: 1 + type: 'weflow-db-snapshots' + createdAt: string + appVersion: string + source: { + wxid: string + dbRoot: string + } + options?: BackupOptions + databases: Array<{ + id: string + kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' + dbPath: string + relativePath: string + tables: Array<{ + name: string + snapshotPath: string + rows: number + columns: number + schemaSql?: string + }> + }> + resources?: { + images?: Array<{ + kind: 'image' | 'video' | 'file' + id: string + md5?: string + sessionId?: string + createTime?: number + sourceFileName?: string + archivePath: string + targetRelativePath: string + ext?: string + size?: number + }> + videos?: Array<{ + kind: 'image' | 'video' | 'file' + id: string + md5?: string + sourceFileName?: string + archivePath: string + targetRelativePath: string + size?: number + }> + files?: Array<{ + kind: 'image' | 'video' | 'file' + id: string + sourceFileName?: string + archivePath: string + targetRelativePath: string + size?: number + }> + } +} + export interface ElectronAPI { window: { minimize: () => void @@ -158,6 +228,27 @@ export interface ElectronAPI { close: () => Promise } + backup: { + create: (payload: { outputPath: string; options?: BackupOptions }) => Promise<{ + success: boolean + filePath?: string + manifest?: BackupManifest + error?: string + }> + inspect: (payload: { archivePath: string }) => Promise<{ + success: boolean + manifest?: BackupManifest + error?: string + }> + restore: (payload: { archivePath: string }) => Promise<{ + success: boolean + inserted?: number + ignored?: number + skipped?: number + error?: string + }> + onProgress: (callback: (progress: BackupProgress) => void) => () => void + } key: { autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }> autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }> @@ -1220,4 +1311,3 @@ declare global { } export { } -