mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-10-25 17:36:32 +08:00
1146 lines
42 KiB
TypeScript
1146 lines
42 KiB
TypeScript
import { BrowserWindow, app } from 'electron'
|
|
import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
|
|
import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
|
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'path'
|
|
import { tmpdir } from 'os'
|
|
import * as tar from 'tar'
|
|
import { ConfigService } from './config'
|
|
import { WcdbService } from './wcdbService'
|
|
import { expandHomePath } from '../utils/pathUtils'
|
|
|
|
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
|
|
type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
|
type BackupResourceKind = 'image' | 'video' | 'file'
|
|
const TEMP_MARKER = '.weflow-backup-temp'
|
|
const TEMP_TTL_MS = 24 * 60 * 60 * 1000
|
|
|
|
export interface BackupOptions {
|
|
includeImages?: boolean
|
|
includeVideos?: boolean
|
|
includeFiles?: boolean
|
|
}
|
|
|
|
interface BackupDbEntry {
|
|
id: string
|
|
kind: BackupDbKind
|
|
dbPath: string
|
|
relativePath: string
|
|
tables: BackupTableEntry[]
|
|
}
|
|
|
|
interface BackupTableEntry {
|
|
name: string
|
|
snapshotPath: string
|
|
rows: number
|
|
columns: number
|
|
schemaSql?: string
|
|
}
|
|
|
|
interface BackupResourceEntry {
|
|
kind: BackupResourceKind
|
|
id: string
|
|
md5?: string
|
|
sessionId?: string
|
|
createTime?: number
|
|
sourceFileName?: string
|
|
archivePath: string
|
|
targetRelativePath: string
|
|
ext?: string
|
|
size?: number
|
|
}
|
|
|
|
interface BackupManifest {
|
|
version: 1
|
|
type: 'weflow-db-snapshots'
|
|
createdAt: string
|
|
appVersion: string
|
|
source: {
|
|
accountId: string
|
|
dbRoot: string
|
|
accountDirName?: string
|
|
}
|
|
databases: BackupDbEntry[]
|
|
options?: BackupOptions
|
|
resources?: {
|
|
images?: BackupResourceEntry[]
|
|
videos?: BackupResourceEntry[]
|
|
files?: BackupResourceEntry[]
|
|
}
|
|
}
|
|
|
|
interface BackupProgress {
|
|
phase: BackupPhase
|
|
message: string
|
|
current?: number
|
|
total?: number
|
|
detail?: string
|
|
}
|
|
|
|
function emitBackupProgress(progress: BackupProgress): void {
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
if (!win.isDestroyed()) {
|
|
win.webContents.send('backup:progress', progress)
|
|
}
|
|
}
|
|
}
|
|
|
|
function safeName(value: string): string {
|
|
return encodeURIComponent(value || 'unnamed').replace(/%/g, '_')
|
|
}
|
|
|
|
function toArchivePath(path: string): string {
|
|
return path.split(sep).join('/')
|
|
}
|
|
|
|
async function withTimeout<T>(task: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
let timer: NodeJS.Timeout | null = null
|
|
try {
|
|
return await Promise.race([
|
|
task,
|
|
new Promise<T>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
|
|
})
|
|
])
|
|
} finally {
|
|
if (timer) clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
function delay(ms = 0): Promise<void> {
|
|
return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
|
|
}
|
|
|
|
function createThrottledProgressEmitter(minIntervalMs = 120): (progress: BackupProgress, force?: boolean) => void {
|
|
let lastEmitAt = 0
|
|
return (progress: BackupProgress, force = false) => {
|
|
const now = Date.now()
|
|
if (!force && now - lastEmitAt < minIntervalMs) return
|
|
lastEmitAt = now
|
|
emitBackupProgress(progress)
|
|
}
|
|
}
|
|
|
|
async function runWithConcurrency<T>(
|
|
items: T[],
|
|
concurrency: number,
|
|
worker: (item: T, index: number) => Promise<void>
|
|
): Promise<void> {
|
|
let nextIndex = 0
|
|
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
|
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
while (true) {
|
|
const index = nextIndex
|
|
nextIndex += 1
|
|
if (index >= items.length) return
|
|
await worker(items[index], index)
|
|
if (index % 50 === 0) await delay()
|
|
}
|
|
}))
|
|
}
|
|
|
|
function hasResourceOptions(options: BackupOptions): boolean {
|
|
return options.includeImages === true || options.includeVideos === true || options.includeFiles === true
|
|
}
|
|
|
|
function normalizeArchivePath(value: string): string {
|
|
return String(value || '').replace(/\\/g, '/')
|
|
}
|
|
|
|
export class BackupService {
|
|
private configService = new ConfigService()
|
|
// Backup inspection/import must never repoint the application's shared WCDB
|
|
// worker to the account recorded in an archive.
|
|
private wcdbService = new WcdbService()
|
|
private cleanedTempDirs = false
|
|
private activeDatabaseOperation = false
|
|
private wcdbRuntime?: { resourcesPath: string; userDataPath: string; logEnabled: boolean }
|
|
|
|
configureRuntime(resourcesPath: string, userDataPath: string, logEnabled: boolean): void {
|
|
this.wcdbRuntime = { resourcesPath, userDataPath, logEnabled }
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
await this.wcdbService.shutdown()
|
|
}
|
|
|
|
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 buildAccountIdCandidates(accountId: string): string[] {
|
|
const accountIdCandidates = Array.from(new Set([
|
|
String(accountId || '').trim(),
|
|
this.cleanAccountDirName(accountId)
|
|
].filter(Boolean)))
|
|
return accountIdCandidates
|
|
}
|
|
|
|
private isCurrentAccountDir(accountDir: string, accountIdCandidates: string[]): boolean {
|
|
const accountName = basename(accountDir).toLowerCase()
|
|
return accountIdCandidates
|
|
.map(item => item.toLowerCase())
|
|
.some(accountId => accountName === accountId || accountName.startsWith(`${accountId}_`))
|
|
}
|
|
|
|
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, accountIdCandidates: 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, accountIdCandidates) && existsSync(current)) {
|
|
return current
|
|
}
|
|
}
|
|
const parent = dirname(current)
|
|
if (!parent || parent === current) break
|
|
current = parent
|
|
}
|
|
return null
|
|
}
|
|
|
|
private resolveCurrentAccountDbStorageFromRoot(rootPath: string, accountIdCandidates: string[]): string | null {
|
|
if (!rootPath || !existsSync(rootPath)) return null
|
|
|
|
for (const candidateAccountId of accountIdCandidates) {
|
|
const viaAccountId = join(rootPath, candidateAccountId, 'db_storage')
|
|
if (existsSync(viaAccountId)) return viaAccountId
|
|
}
|
|
|
|
try {
|
|
const entries = readdirSync(rootPath)
|
|
const loweredAccountIds = accountIdCandidates.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 (!loweredAccountIds.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, accountId: string): string | null {
|
|
const normalized = this.normalizeExistingPath(dbPath)
|
|
if (!normalized) return null
|
|
|
|
const accountIdCandidates = this.buildAccountIdCandidates(accountId)
|
|
const ancestor = this.resolveAncestorDbStorage(normalized, accountIdCandidates)
|
|
if (ancestor) return ancestor
|
|
|
|
const direct = join(normalized, 'db_storage')
|
|
if (existsSync(direct) && this.isCurrentAccountDir(normalized, accountIdCandidates)) return direct
|
|
|
|
const roots = Array.from(new Set([
|
|
normalized
|
|
]))
|
|
for (const root of roots) {
|
|
const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, accountIdCandidates)
|
|
if (dbStorage) return dbStorage
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
private resolveAccountDir(dbPath: string, accountId: string): string | null {
|
|
const dbStorage = this.resolveDbStoragePath(dbPath, accountId)
|
|
return dbStorage ? dirname(dbStorage) : null
|
|
}
|
|
|
|
private cleanAccountDirName(accountId: string): string {
|
|
const trimmed = String(accountId || '').trim()
|
|
if (trimmed.toLowerCase().startsWith('accountId_')) {
|
|
const match = trimmed.match(/^(accountId_[^_]+)/i)
|
|
return match?.[1] || trimmed
|
|
}
|
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
|
return suffixMatch ? suffixMatch[1] : trimmed
|
|
}
|
|
|
|
private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise<string[]> {
|
|
const dir = join(root, rel)
|
|
const files: string[] = []
|
|
for (const entry of readdirSync(dir)) {
|
|
const entryRel = rel ? join(rel, entry) : entry
|
|
const entryPath = join(root, entryRel)
|
|
try {
|
|
const stat = statSync(entryPath)
|
|
if (stat.isDirectory()) {
|
|
files.push(...await this.listFilesForArchive(root, entryRel, state))
|
|
} else if (stat.isFile()) {
|
|
files.push(toArchivePath(entryRel))
|
|
}
|
|
state.visited += 1
|
|
if (state.visited % 200 === 0) await delay()
|
|
} catch {}
|
|
}
|
|
return files
|
|
}
|
|
|
|
private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
|
|
const normalized = normalizeArchivePath(archivePath)
|
|
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
|
const root = resolve(extractDir)
|
|
const target = resolve(join(extractDir, normalized))
|
|
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
|
return target
|
|
}
|
|
|
|
private resolveStagingPath(stagingDir: string, archivePath: string): string | null {
|
|
return this.resolveExtractedPath(stagingDir, archivePath)
|
|
}
|
|
|
|
private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
|
|
const normalized = normalizeArchivePath(relativePath)
|
|
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
|
const root = resolve(accountDir)
|
|
const target = resolve(join(accountDir, normalized))
|
|
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
|
return target
|
|
}
|
|
|
|
private isSafeAccountRelativePath(accountDir: string, filePath: string): string | null {
|
|
const rel = toArchivePath(relative(accountDir, filePath))
|
|
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null
|
|
return rel
|
|
}
|
|
|
|
private async listFilesUnderDir(root: string, state = { visited: 0 }): Promise<string[]> {
|
|
const files: string[] = []
|
|
if (!existsSync(root)) return files
|
|
try {
|
|
for (const entry of readdirSync(root)) {
|
|
const fullPath = join(root, entry)
|
|
let stat
|
|
try {
|
|
stat = statSync(fullPath)
|
|
} catch {
|
|
continue
|
|
}
|
|
if (stat.isDirectory()) {
|
|
files.push(...await this.listFilesUnderDir(fullPath, state))
|
|
} else if (stat.isFile()) {
|
|
files.push(fullPath)
|
|
}
|
|
state.visited += 1
|
|
if (state.visited % 300 === 0) await delay()
|
|
}
|
|
} catch {}
|
|
return files
|
|
}
|
|
|
|
private async stagePlainResource(sourcePath: string, outputPath: string): Promise<void> {
|
|
mkdirSync(dirname(outputPath), { recursive: true })
|
|
try {
|
|
await link(sourcePath, outputPath)
|
|
} catch {
|
|
await copyFile(sourcePath, outputPath)
|
|
}
|
|
}
|
|
|
|
private async writeTarEntryToFile(entry: any, outputPath: string): Promise<void> {
|
|
mkdirSync(dirname(outputPath), { recursive: true })
|
|
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
const out = createWriteStream(outputPath)
|
|
const fail = (error: unknown) => rejectPromise(error instanceof Error ? error : new Error(String(error)))
|
|
out.on('finish', resolvePromise)
|
|
out.on('error', fail)
|
|
entry.on('error', fail)
|
|
entry.pipe(out)
|
|
})
|
|
}
|
|
|
|
private async listChatImageDatFiles(accountDir: string): Promise<string[]> {
|
|
const attachRoot = join(accountDir, 'msg', 'attach')
|
|
const result: string[] = []
|
|
if (!existsSync(attachRoot)) return result
|
|
|
|
const scanImgDir = async (imgDir: string): Promise<void> => {
|
|
let entries: string[] = []
|
|
try {
|
|
entries = readdirSync(imgDir)
|
|
} catch {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
const fullPath = join(imgDir, entry)
|
|
let stat
|
|
try {
|
|
stat = statSync(fullPath)
|
|
} catch {
|
|
continue
|
|
}
|
|
if (stat.isFile() && entry.toLowerCase().endsWith('.dat')) {
|
|
result.push(fullPath)
|
|
} else if (stat.isDirectory()) {
|
|
let nestedEntries: string[] = []
|
|
try {
|
|
nestedEntries = readdirSync(fullPath)
|
|
} catch {
|
|
continue
|
|
}
|
|
for (const nestedEntry of nestedEntries) {
|
|
const nestedPath = join(fullPath, nestedEntry)
|
|
try {
|
|
if (statSync(nestedPath).isFile() && nestedEntry.toLowerCase().endsWith('.dat')) {
|
|
result.push(nestedPath)
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
if (result.length > 0 && result.length % 500 === 0) await delay()
|
|
}
|
|
}
|
|
|
|
const walk = async (dir: string): Promise<void> => {
|
|
let entries: Array<{ name: string; isDirectory: () => boolean }> = []
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue
|
|
const child = join(dir, entry.name)
|
|
if (entry.name.toLowerCase() === 'img') {
|
|
await scanImgDir(child)
|
|
} else {
|
|
await walk(child)
|
|
}
|
|
if (result.length > 0 && result.length % 500 === 0) await delay()
|
|
}
|
|
}
|
|
|
|
await walk(attachRoot)
|
|
return Array.from(new Set(result))
|
|
}
|
|
|
|
private async ensureConnected(
|
|
accountIdOverride?: string,
|
|
exactAccountDirName?: string
|
|
): Promise<{ success: boolean; accountId?: string; accountDirName?: string; dbPath?: string; dbStorage?: string; error?: string }> {
|
|
if (this.wcdbRuntime) {
|
|
this.wcdbService.setPaths(this.wcdbRuntime.resourcesPath, this.wcdbRuntime.userDataPath)
|
|
this.wcdbService.setLibPath(this.configService.get('wcdbLibPath') || '')
|
|
this.wcdbService.setLogEnabled(this.wcdbRuntime.logEnabled)
|
|
}
|
|
const configuredAccountId = String(this.configService.getMyAccountIdCleaned() || '').trim()
|
|
const accountId = String(accountIdOverride || configuredAccountId || '').trim()
|
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
|
const decryptKey = String(this.configService.get('decryptKey') || '').trim()
|
|
if (!accountId || !dbPath) return { success: false, error: '请先配置数据库路径和账号' }
|
|
if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
|
|
|
|
// 使用 ConfigService 统一解析账号目录
|
|
const safeExactName = String(exactAccountDirName || '').trim()
|
|
const exactCandidate = safeExactName && basename(safeExactName) === safeExactName
|
|
? join(dbPath, safeExactName)
|
|
: ''
|
|
const exactBelongsToAccountId = Boolean(exactCandidate && this.isCurrentAccountDir(exactCandidate, this.buildAccountIdCandidates(accountId)))
|
|
const accountDir = exactBelongsToAccountId && existsSync(join(exactCandidate, 'db_storage'))
|
|
? exactCandidate
|
|
: this.configService.getAccountDir(dbPath, accountId)
|
|
if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${accountId}` }
|
|
const dbStorage = join(accountDir, 'db_storage')
|
|
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
|
|
|
|
const accountDirName = basename(accountDir)
|
|
const opened = await withTimeout(
|
|
this.wcdbService.open(accountDir, decryptKey),
|
|
15000,
|
|
'连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
|
|
)
|
|
if (!opened) {
|
|
const detail = await this.wcdbService.getLastInitError().catch(() => null)
|
|
return { success: false, error: detail || `目标账号 ${accountDir} 数据库连接失败` }
|
|
}
|
|
|
|
return { success: true, accountId, accountDirName, dbPath, dbStorage }
|
|
}
|
|
|
|
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
|
|
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') return kind
|
|
return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
|
|
}
|
|
|
|
private toDbRelativePath(dbStorage: string, dbPath: string): string {
|
|
const rel = toArchivePath(relative(dbStorage, dbPath))
|
|
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return basename(dbPath)
|
|
return rel
|
|
}
|
|
|
|
private resolveTargetDbPath(dbStorage: string, relativePath: string): string | null {
|
|
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
|
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
|
const root = resolve(dbStorage)
|
|
const target = resolve(join(dbStorage, normalized))
|
|
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
|
return target
|
|
}
|
|
|
|
private defaultRelativeDbPath(kind: BackupDbKind): string | null {
|
|
if (kind === 'session') return 'session/session.db'
|
|
if (kind === 'contact') return 'contact/contact.db'
|
|
if (kind === 'emoticon') return 'emoticon/emoticon.db'
|
|
if (kind === 'sns') return 'sns/sns.db'
|
|
if (kind === 'hardlink') return 'hardlink/hardlink.db'
|
|
return null
|
|
}
|
|
|
|
private resolveRestoreTargetDbPath(dbStorage: string, db: BackupDbEntry): string | null {
|
|
const normalized = String(db.relativePath || '').replace(/\\/g, '/')
|
|
const legacyFixedPath = this.defaultRelativeDbPath(db.kind)
|
|
if (legacyFixedPath && (!normalized.includes('/') || !normalized.toLowerCase().endsWith('.db'))) {
|
|
return this.resolveTargetDbPath(dbStorage, legacyFixedPath)
|
|
}
|
|
return this.resolveTargetDbPath(dbStorage, db.relativePath)
|
|
}
|
|
|
|
private findFirstExisting(paths: string[]): string {
|
|
for (const path of paths) {
|
|
try {
|
|
if (existsSync(path) && statSync(path).isFile()) return path
|
|
} catch {}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
private resolveKnownDbPath(kind: BackupDbKind, dbStorage: string): string {
|
|
if (kind === 'session') {
|
|
return this.findFirstExisting([
|
|
join(dbStorage, 'session', 'session.db'),
|
|
join(dbStorage, 'Session', 'session.db'),
|
|
join(dbStorage, 'session.db')
|
|
])
|
|
}
|
|
if (kind === 'contact') {
|
|
return this.findFirstExisting([
|
|
join(dbStorage, 'Contact', 'contact.db'),
|
|
join(dbStorage, 'Contact', 'Contact.db'),
|
|
join(dbStorage, 'contact', 'contact.db'),
|
|
join(dbStorage, 'session', 'contact.db')
|
|
])
|
|
}
|
|
if (kind === 'emoticon') {
|
|
return this.findFirstExisting([
|
|
join(dbStorage, 'emoticon', 'emoticon.db'),
|
|
join(dbStorage, 'emotion', 'emoticon.db')
|
|
])
|
|
}
|
|
if (kind === 'sns') {
|
|
return this.findFirstExisting([
|
|
join(dbStorage, 'sns', 'sns.db'),
|
|
join(dirname(dbStorage), 'sns', 'sns.db')
|
|
])
|
|
}
|
|
if (kind === 'hardlink') {
|
|
return this.findFirstExisting([
|
|
join(dbStorage, 'hardlink', 'hardlink.db'),
|
|
join(dbStorage, 'hardlink.db'),
|
|
join(dirname(dbStorage), 'hardlink.db')
|
|
])
|
|
}
|
|
return ''
|
|
}
|
|
|
|
private async collectDatabases(dbStorage: string): Promise<Array<Omit<BackupDbEntry, 'tables'>>> {
|
|
const result: Array<Omit<BackupDbEntry, 'tables'>> = []
|
|
for (const kind of ['session', 'contact', 'emoticon', 'sns', 'hardlink'] as const) {
|
|
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
|
|
result.push({
|
|
id: kind,
|
|
kind,
|
|
dbPath,
|
|
relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind
|
|
})
|
|
}
|
|
|
|
const messageDbs = await this.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 this.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: { accountId: string; dbStorage: string },
|
|
stagingDir: string,
|
|
manifest: BackupManifest
|
|
): Promise<void> {
|
|
const accountDir = dirname(connected.dbStorage)
|
|
const imagesDir = join(stagingDir, 'resources', 'images')
|
|
const imagePaths = await this.listChatImageDatFiles(accountDir)
|
|
if (imagePaths.length === 0) return
|
|
|
|
mkdirSync(imagesDir, { recursive: true })
|
|
const resources: BackupResourceEntry[] = []
|
|
const emitImageProgress = createThrottledProgressEmitter(160)
|
|
for (let index = 0; index < imagePaths.length; index += 1) {
|
|
const sourcePath = imagePaths[index]
|
|
const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
|
|
if (!relativeTarget) continue
|
|
emitImageProgress({
|
|
phase: 'exporting',
|
|
message: '正在打包图片资源',
|
|
current: index + 1,
|
|
total: imagePaths.length,
|
|
detail: relativeTarget
|
|
})
|
|
const archivePath = toArchivePath(join('resources', 'images', relativeTarget))
|
|
const outputPath = join(stagingDir, archivePath)
|
|
await this.stagePlainResource(sourcePath, outputPath)
|
|
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
|
|
const stat = statSync(sourcePath)
|
|
resources.push({
|
|
kind: 'image',
|
|
id: relativeTarget,
|
|
md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined,
|
|
sourceFileName: basename(sourcePath),
|
|
archivePath,
|
|
targetRelativePath: relativeTarget,
|
|
size: stat.size
|
|
})
|
|
if (index % 20 === 0) await delay()
|
|
}
|
|
|
|
if (resources.length > 0) {
|
|
manifest.resources = { ...(manifest.resources || {}), images: resources }
|
|
}
|
|
}
|
|
|
|
private async collectPlainResources(
|
|
connected: { dbStorage: string },
|
|
stagingDir: string,
|
|
manifest: BackupManifest,
|
|
kind: 'video' | 'file'
|
|
): Promise<void> {
|
|
const accountDir = dirname(connected.dbStorage)
|
|
const roots = kind === 'video'
|
|
? [
|
|
join(accountDir, 'msg', 'video'),
|
|
join(accountDir, 'FileStorage', 'Video')
|
|
]
|
|
: [
|
|
join(accountDir, 'FileStorage', 'File'),
|
|
join(accountDir, 'msg', 'file')
|
|
]
|
|
const listed = await Promise.all(roots.map(root => this.listFilesUnderDir(root)))
|
|
const uniqueFiles = Array.from(new Set(listed.flat()))
|
|
if (uniqueFiles.length === 0) return
|
|
|
|
const resources: BackupResourceEntry[] = []
|
|
const bucket = kind === 'video' ? 'videos' : 'files'
|
|
const emitResourceProgress = createThrottledProgressEmitter(180)
|
|
await runWithConcurrency(uniqueFiles, 4, async (sourcePath, index) => {
|
|
emitResourceProgress({
|
|
phase: 'exporting',
|
|
message: kind === 'video' ? '正在归档视频资源' : '正在归档文件资源',
|
|
current: index + 1,
|
|
total: uniqueFiles.length,
|
|
detail: basename(sourcePath)
|
|
})
|
|
const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
|
|
if (!relativeTarget) return
|
|
const archivePath = toArchivePath(join('resources', bucket, relativeTarget))
|
|
const outputPath = join(stagingDir, archivePath)
|
|
await this.stagePlainResource(sourcePath, outputPath)
|
|
let size = 0
|
|
try { size = statSync(sourcePath).size } catch {}
|
|
const entry: BackupResourceEntry = {
|
|
kind,
|
|
id: relativeTarget,
|
|
sourceFileName: basename(sourcePath),
|
|
archivePath,
|
|
targetRelativePath: relativeTarget,
|
|
size
|
|
}
|
|
resources.push(entry)
|
|
})
|
|
|
|
if (resources.length > 0) {
|
|
manifest.resources = {
|
|
...(manifest.resources || {}),
|
|
[bucket]: resources
|
|
}
|
|
}
|
|
}
|
|
|
|
async createBackup(outputPath: string, options: BackupOptions = {}): Promise<{ success: boolean; filePath?: string; manifest?: BackupManifest; error?: string }> {
|
|
if (this.activeDatabaseOperation) return { success: false, error: '已有备份或恢复任务正在运行' }
|
|
this.activeDatabaseOperation = true
|
|
let stagingDir = ''
|
|
try {
|
|
emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' })
|
|
const connected = await this.ensureConnected()
|
|
if (!connected.success || !connected.accountId || !connected.dbPath || !connected.dbStorage) {
|
|
return { success: false, error: connected.error || '数据库未连接' }
|
|
}
|
|
|
|
stagingDir = await this.createTempDir('weflow-backup-')
|
|
const snapshotsDir = join(stagingDir, 'snapshots')
|
|
mkdirSync(snapshotsDir, { recursive: true })
|
|
|
|
const dbs = await this.collectDatabases(connected.dbStorage)
|
|
const manifest: BackupManifest = {
|
|
version: 1,
|
|
type: 'weflow-db-snapshots',
|
|
createdAt: new Date().toISOString(),
|
|
appVersion: app.getVersion(),
|
|
source: {
|
|
accountId: connected.accountId,
|
|
dbRoot: connected.dbPath,
|
|
accountDirName: connected.accountDirName
|
|
},
|
|
databases: [],
|
|
options: {
|
|
includeImages: options.includeImages === true,
|
|
includeVideos: options.includeVideos === true,
|
|
includeFiles: options.includeFiles === true
|
|
}
|
|
}
|
|
|
|
const tableJobs: Array<{ db: Omit<BackupDbEntry, 'tables'>; table: string; schemaSql: string; snapshotPath: string; outputPath: string }> = []
|
|
for (let index = 0; index < dbs.length; index += 1) {
|
|
const db = dbs[index]
|
|
emitBackupProgress({
|
|
phase: 'scanning',
|
|
message: '正在扫描数据库和表',
|
|
current: index + 1,
|
|
total: dbs.length,
|
|
detail: `${db.kind}:${db.relativePath || db.dbPath || db.id}`
|
|
})
|
|
const tablesResult = await this.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 this.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 this.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(
|
|
{ accountId: connected.accountId, 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 {}
|
|
}
|
|
this.activeDatabaseOperation = false
|
|
await this.wcdbService.shutdown().catch(() => {})
|
|
}
|
|
}
|
|
|
|
async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> {
|
|
let extractDir = ''
|
|
try {
|
|
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
|
|
extractDir = await this.createTempDir('weflow-backup-inspect-')
|
|
await tar.x({
|
|
file: archivePath,
|
|
cwd: extractDir,
|
|
filter: (entryPath: string) => entryPath.replace(/\\/g, '/') === 'manifest.json'
|
|
} as any)
|
|
const manifestPath = join(extractDir, 'manifest.json')
|
|
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
|
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
|
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
|
emitBackupProgress({ phase: 'failed', message: '不支持的备份包格式' })
|
|
return { success: false, error: '不支持的备份包格式' }
|
|
}
|
|
emitBackupProgress({ phase: 'done', message: '备份包已读取' })
|
|
return { success: true, manifest }
|
|
} catch (e) {
|
|
emitBackupProgress({ phase: 'failed', message: e instanceof Error ? e.message : String(e) })
|
|
return { success: false, error: e instanceof Error ? e.message : String(e) }
|
|
} finally {
|
|
if (extractDir) {
|
|
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async streamRestoreArchive(
|
|
archivePath: string,
|
|
extractDir: string,
|
|
manifest: BackupManifest,
|
|
connected: { dbStorage: string; accountId?: 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 }> {
|
|
if (this.activeDatabaseOperation) return { success: false, error: '已有备份或恢复任务正在运行' }
|
|
this.activeDatabaseOperation = true
|
|
let extractDir = ''
|
|
try {
|
|
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份信息' })
|
|
extractDir = await this.createTempDir('weflow-backup-restore-')
|
|
await tar.x({
|
|
file: archivePath,
|
|
cwd: extractDir,
|
|
filter: (entryPath: string) => normalizeArchivePath(entryPath) === 'manifest.json'
|
|
} as any)
|
|
const manifestPath = join(extractDir, 'manifest.json')
|
|
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
|
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
|
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
|
return { success: false, error: '不支持的备份包格式' }
|
|
}
|
|
const rawTargetAccountId = String(manifest.source?.accountId || '').trim()
|
|
const legacyAbsoluteAccountDir = rawTargetAccountId && (isAbsolute(rawTargetAccountId) || win32.isAbsolute(rawTargetAccountId) || posix.isAbsolute(rawTargetAccountId))
|
|
? (win32.isAbsolute(rawTargetAccountId) ? win32.basename(rawTargetAccountId) : posix.basename(rawTargetAccountId))
|
|
: ''
|
|
const targetAccountId = legacyAbsoluteAccountDir || rawTargetAccountId
|
|
const targetAccountDirName = String(manifest.source?.accountDirName || legacyAbsoluteAccountDir || '').trim()
|
|
if (!targetAccountId) return { success: false, error: '备份包缺少来源账号 accountId,无法定位目标账号目录' }
|
|
|
|
const configuredAccountId = String(this.configService.getMyAccountIdCleaned() || '').trim()
|
|
const configuredDbPath = String(this.configService.get('dbPath') || '').trim()
|
|
const configuredAccountDir = configuredAccountId && configuredDbPath
|
|
? this.configService.getAccountDir(configuredDbPath, configuredAccountId)
|
|
: null
|
|
if (!configuredAccountId || !configuredAccountDir) {
|
|
return { success: false, error: '请先切换并连接备份所属的账号,再执行恢复' }
|
|
}
|
|
if (this.cleanAccountDirName(targetAccountId).toLowerCase() !== this.cleanAccountDirName(configuredAccountId).toLowerCase()) {
|
|
return { success: false, error: '备份所属账号与当前配置账号不一致,已拒绝跨账号写入' }
|
|
}
|
|
const configuredAccountDirName = basename(configuredAccountDir)
|
|
if (targetAccountDirName && targetAccountDirName.toLowerCase() !== configuredAccountDirName.toLowerCase()) {
|
|
return { success: false, error: '备份所属账号目录与当前实际账号目录不一致,已拒绝恢复' }
|
|
}
|
|
|
|
emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetAccountId })
|
|
const connected = await this.ensureConnected(configuredAccountId, configuredAccountDirName)
|
|
if (!connected.success || !connected.dbStorage) return { success: false, error: connected.error || '数据库未连接' }
|
|
|
|
const tableJobs = manifest.databases.flatMap(db => db.tables.map(table => ({ db, table })))
|
|
const imageJobs = manifest.resources?.images || []
|
|
const plainResourceJobs = [
|
|
...(manifest.resources?.videos || []),
|
|
...(manifest.resources?.files || [])
|
|
]
|
|
const totalRestoreJobs = tableJobs.length + imageJobs.length + plainResourceJobs.length
|
|
let inserted = 0
|
|
let ignored = 0
|
|
let skipped = 0
|
|
let current = 0
|
|
if (imageJobs.length > 0 || plainResourceJobs.length > 0 || tableJobs.length > 0) {
|
|
emitBackupProgress({
|
|
phase: 'inspecting',
|
|
message: '正在按需读取备份包',
|
|
current: 0,
|
|
total: totalRestoreJobs,
|
|
detail: archivePath
|
|
})
|
|
const streamed = await this.streamRestoreArchive(
|
|
archivePath,
|
|
extractDir,
|
|
manifest,
|
|
{ dbStorage: connected.dbStorage, accountId: connected.accountId },
|
|
0,
|
|
totalRestoreJobs
|
|
)
|
|
current = streamed.current
|
|
skipped += streamed.skipped
|
|
}
|
|
|
|
for (const job of tableJobs) {
|
|
current++
|
|
const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db)
|
|
if (targetDbPath === null) {
|
|
skipped++
|
|
continue
|
|
}
|
|
if (!job.table.schemaSql) {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
emitBackupProgress({
|
|
phase: 'restoring',
|
|
message: '正在通过 WCDB 写入数据库',
|
|
current,
|
|
total: totalRestoreJobs,
|
|
detail: `${job.db.kind}:${job.table.name}`
|
|
})
|
|
const inputPath = this.resolveExtractedPath(extractDir, job.table.snapshotPath)
|
|
if (!inputPath || !existsSync(inputPath)) {
|
|
skipped++
|
|
continue
|
|
}
|
|
mkdirSync(dirname(targetDbPath), { recursive: true })
|
|
const restored = await this.wcdbService.importTableSnapshotWithSchema(
|
|
job.db.kind,
|
|
targetDbPath,
|
|
job.table.name,
|
|
inputPath,
|
|
job.table.schemaSql
|
|
)
|
|
if (!restored.success) {
|
|
skipped++
|
|
continue
|
|
}
|
|
inserted += restored.inserted || 0
|
|
ignored += restored.ignored || 0
|
|
if (current % 4 === 0) await delay()
|
|
}
|
|
|
|
emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
|
|
return { success: true, inserted, ignored, skipped }
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e.message : String(e)
|
|
emitBackupProgress({ phase: 'failed', message: error })
|
|
return { success: false, error }
|
|
} finally {
|
|
if (extractDir) {
|
|
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
|
|
}
|
|
this.activeDatabaseOperation = false
|
|
await this.wcdbService.shutdown().catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
|
|
export const backupService = new BackupService()
|