package backend import ( "ant-chrome/backend/internal/backup" "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/logger" "ant-chrome/backend/internal/proxy" "archive/zip" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "sort" "strings" "time" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // BackupInitializeSystem 初始化系统到最开始状态。 func (a *App) BackupInitializeSystem() (map[string]interface{}, error) { a.maintenanceMu.Lock() defer a.maintenanceMu.Unlock() return a.backupInitializeLocked(true) } // BackupExportPackage 导出全量配置与数据到 ZIP。 func (a *App) BackupExportPackage() (map[string]interface{}, error) { a.maintenanceMu.Lock() defer a.maintenanceMu.Unlock() if a.ctx == nil { return nil, fmt.Errorf("应用上下文未初始化") } a.backupEmitExportProgress("starting", 0, "等待选择导出路径...") defaultName := fmt.Sprintf("ant-chrome-backup-%s.zip", time.Now().Format("20060102-150405")) savePath, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{ Title: "导出配置", DefaultFilename: defaultName, Filters: []wailsruntime.FileFilter{ {DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"}, }, }) if err != nil { return nil, fmt.Errorf("打开保存对话框失败: %w", err) } if strings.TrimSpace(savePath) == "" { a.backupEmitExportProgress("cancelled", 0, "已取消导出") return map[string]interface{}{ "cancelled": true, "message": "已取消导出", }, nil } savePath = backupEnsureZipSuffix(savePath) a.backupEmitExportProgress("preparing", 8, "正在收集导出范围...") scope, err := backup.BuildScope(backup.BuildOptions{AppRoot: a.appRoot, Config: a.config}) if err != nil { a.backupEmitExportProgress("error", 100, fmt.Sprintf("导出失败: %v", err)) return nil, err } manifest := backup.BuildManifest(scope, a.appName(), a.appVersion(), time.Now()) a.backupEmitExportProgress("preparing", 15, "开始写入备份包...") includedEntries, skippedEntries, fileCount, err := backupWritePackageZip(savePath, scope, manifest, a.backupEmitExportProgressMeta) if err != nil { a.backupEmitExportProgress("error", 100, fmt.Sprintf("导出失败: %v", err)) return nil, err } return map[string]interface{}{ "cancelled": false, "zipPath": savePath, "includedEntries": includedEntries, "skippedEntries": skippedEntries, "fileCount": fileCount, "message": "导出完成", }, nil } // BackupImportPackage 从 ZIP 加载配置与数据。 // resetFirst=true: 先初始化,再全量导入。 // resetFirst=false: 直接导入并执行判重合并。 func (a *App) BackupImportPackage(resetFirst bool) (map[string]interface{}, error) { a.maintenanceMu.Lock() defer a.maintenanceMu.Unlock() if a.ctx == nil { return nil, fmt.Errorf("应用上下文未初始化") } a.backupEmitImportProgress("starting", 0, "等待选择 ZIP 配置文件...") zipPath, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{ Title: "加载配置", Filters: []wailsruntime.FileFilter{ {DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"}, }, }) if err != nil { a.backupEmitImportProgress("error", 100, fmt.Sprintf("打开文件对话框失败: %v", err)) return nil, fmt.Errorf("打开文件对话框失败: %w", err) } if strings.TrimSpace(zipPath) == "" { a.backupEmitImportProgress("cancelled", 0, "已取消加载") return map[string]interface{}{ "cancelled": true, "message": "已取消加载", }, nil } a.backupEmitImportProgress("preparing", 5, "正在校验备份包...") result, importErr := a.backupImportFromPathLocked(zipPath, resetFirst) if importErr != nil { a.backupEmitImportProgress("error", 100, fmt.Sprintf("加载失败: %v", importErr)) return nil, importErr } return result, nil } type backupMergeStats struct { Imported int Skipped int Conflicts int } type backupImportIssue struct { ComponentID string `json:"componentId"` ComponentName string `json:"componentName"` Error string `json:"error"` } type backupProgressMeta struct { ComponentID string ComponentName string EntryIndex int EntryTotal int } type backupProgressEvent struct { Phase string `json:"phase"` Progress int `json:"progress"` Message string `json:"message"` ComponentID string `json:"componentId,omitempty"` ComponentName string `json:"componentName,omitempty"` EntryIndex int `json:"entryIndex,omitempty"` EntryTotal int `json:"entryTotal,omitempty"` Timestamp string `json:"timestamp,omitempty"` } func (a *App) backupEmitExportProgress(phase string, progress int, message string) { a.backupEmitExportProgressMeta(phase, progress, message, nil) } func (a *App) backupEmitExportProgressMeta(phase string, progress int, message string, meta *backupProgressMeta) { a.backupEmitProgress("backup:export:progress", phase, progress, message, meta) } func (a *App) backupEmitImportProgress(phase string, progress int, message string) { a.backupEmitImportProgressMeta(phase, progress, message, nil) } func (a *App) backupEmitImportProgressMeta(phase string, progress int, message string, meta *backupProgressMeta) { a.backupEmitProgress("backup:import:progress", phase, progress, message, meta) } func (a *App) backupEmitProgress(eventName, phase string, progress int, message string, meta *backupProgressMeta) { if a == nil || a.ctx == nil { return } if progress < 0 { progress = 0 } if progress > 100 { progress = 100 } evt := backupProgressEvent{ Phase: strings.TrimSpace(phase), Progress: progress, Message: strings.TrimSpace(message), Timestamp: time.Now().Format("15:04:05"), } if meta != nil { evt.ComponentID = strings.TrimSpace(meta.ComponentID) evt.ComponentName = strings.TrimSpace(meta.ComponentName) evt.EntryIndex = meta.EntryIndex evt.EntryTotal = meta.EntryTotal } wailsruntime.EventsEmit(a.ctx, eventName, backupProgressEvent{ Phase: evt.Phase, Progress: evt.Progress, Message: evt.Message, ComponentID: evt.ComponentID, ComponentName: evt.ComponentName, EntryIndex: evt.EntryIndex, EntryTotal: evt.EntryTotal, Timestamp: evt.Timestamp, }) } func (a *App) backupInitializeLocked(applyReload bool) (map[string]interface{}, error) { log := logger.New("Backup") a.backupStopRuntimeForMaintenance() defaultCfg := config.DefaultConfig() oldCfg := a.config if oldCfg == nil { oldCfg = config.DefaultConfig() } activeDBPath := a.backupResolveDBPath(oldCfg) keepFiles := map[string]struct{}{ backupNormalizePath(activeDBPath): {}, backupNormalizePath(activeDBPath + "-wal"): {}, backupNormalizePath(activeDBPath + "-shm"): {}, } if err := defaultCfg.Save(a.resolveAppPath("config.yaml")); err != nil { return nil, fmt.Errorf("写入默认配置失败: %w", err) } a.config = defaultCfg a.applyRuntimeConfig(defaultCfg.Runtime) _ = os.Remove(a.resolveAppPath("proxies.yaml")) if err := a.backupClearBusinessTables(); err != nil { return nil, err } cleared := make([]string, 0, 3) dataRoot := a.resolveAppPath("data") if err := backupRemoveContentsExcept(dataRoot, keepFiles); err == nil { cleared = append(cleared, dataRoot) } oldUserRoot := a.backupResolveUserDataRoot(oldCfg) newUserRoot := a.backupResolveUserDataRoot(defaultCfg) for _, p := range backupUniqueNonEmpty([]string{oldUserRoot, newUserRoot}) { if backupSamePath(p, dataRoot) { continue } if err := backupRemoveContentsExcept(p, keepFiles); err == nil { cleared = append(cleared, p) } } if applyReload { if err := a.backupReloadAfterMutation(); err != nil { return nil, err } } log.Info("系统初始化完成", logger.F("cleared_dirs", strings.Join(cleared, ";"))) return map[string]interface{}{ "cancelled": false, "resetDone": true, "clearedDirs": cleared, "message": "系统已初始化到默认状态", }, nil } func (a *App) backupImportFromPathLocked(zipPath string, resetFirst bool) (map[string]interface{}, error) { a.backupStopRuntimeForMaintenance() a.backupEmitImportProgress("preparing", 10, "正在解压并校验备份包...") extractRoot, manifest, err := backupExtractAndValidate(zipPath) if err != nil { return nil, err } defer os.RemoveAll(extractRoot) a.backupEmitImportProgress("preparing", 20, "备份包校验通过,开始加载数据...") componentEntries := backupDetectPresentManifestEntries(extractRoot, manifest) componentUniverse := make(map[string]struct{}, len(componentEntries)) for id := range componentEntries { componentUniverse[id] = struct{}{} } failedComponentIDs := map[string]struct{}{} issues := make([]backupImportIssue, 0) recordIssue := func(componentID, componentName string, err error) { if err == nil { return } componentID = strings.TrimSpace(componentID) componentName = strings.TrimSpace(componentName) if componentID != "" { componentUniverse[componentID] = struct{}{} failedComponentIDs[componentID] = struct{}{} if componentName == "" { if entry, ok := componentEntries[componentID]; ok { componentName = backupResolveManifestComponentName(entry) } } } if componentName == "" { componentName = "未知模块" } issues = append(issues, backupImportIssue{ ComponentID: componentID, ComponentName: componentName, Error: err.Error(), }) } stats := &backupMergeStats{} if resetFirst { a.backupEmitImportProgress("preparing", 30, "正在初始化系统数据...") if _, err := a.backupInitializeLocked(false); err != nil { return nil, err } a.backupEmitImportProgress("preparing", 40, "初始化完成,继续加载备份内容...") } payloadRoot := filepath.Join(extractRoot, "payload") a.backupEmitImportProgress("importing", 50, "正在解析备份配置...") incomingCfg, hasIncomingCfg, err := backupLoadIncomingConfig(payloadRoot) if err != nil { recordIssue("system_config_main", "主配置文件", fmt.Errorf("解析配置失败: %w", err)) incomingCfg = nil hasIncomingCfg = false } if resetFirst && !hasIncomingCfg { recordIssue("system_config_main", "主配置文件", fmt.Errorf("备份包缺少 payload/system/config.yaml,已保留默认配置继续加载其余模块")) } if hasIncomingCfg { a.backupEmitImportProgress("importing", 58, "正在应用系统配置...") if err := a.backupApplyIncomingConfig(incomingCfg, resetFirst); err != nil { recordIssue("system_config_main", "主配置文件", err) } } a.backupEmitImportProgress("importing", 66, "正在合并代理配置...") if err := a.backupMergeProxiesFile(payloadRoot, resetFirst, stats); err != nil { recordIssue("system_config_proxies", "代理配置文件", err) } if dbSrc := backupFindDatabaseFile(payloadRoot); dbSrc != "" { a.backupEmitImportProgress("importing", 76, "正在合并数据库数据...") if err := a.backupMergeDatabaseFromSource(dbSrc, resetFirst, stats); err != nil { recordIssue("database_sqlite_main", "SQLite 主数据库", err) } } else if _, ok := componentEntries["database_sqlite_main"]; ok { recordIssue("database_sqlite_main", "SQLite 主数据库", fmt.Errorf("备份包缺少数据库文件")) } a.backupEmitImportProgress("importing", 86, "正在同步文件数据...") a.backupImportFileTrees(payloadRoot, incomingCfg, resetFirst, stats, recordIssue) a.backupEmitImportProgress("importing", 94, "正在刷新运行时配置...") if err := a.backupReloadAfterMutation(); err != nil { return nil, err } totalComponents := len(componentUniverse) failedCount := len(failedComponentIDs) successCount := totalComponents - failedCount if successCount < 0 { successCount = 0 } partial := failedCount > 0 message := "加载完成" if partial { message = fmt.Sprintf("加载完成(部分成功):成功 %d 个模块,异常 %d 个模块", successCount, failedCount) } a.backupEmitImportProgress("done", 100, message) failedComponents := make([]map[string]string, 0, len(issues)) for _, item := range issues { failedComponents = append(failedComponents, map[string]string{ "componentId": item.ComponentID, "componentName": item.ComponentName, "error": item.Error, }) } return map[string]interface{}{ "cancelled": false, "zipPath": zipPath, "resetFirst": resetFirst, "imported": stats.Imported, "skipped": stats.Skipped, "conflicts": stats.Conflicts, "partial": partial, "componentTotal": totalComponents, "componentSuccess": successCount, "componentFailed": failedCount, "failedComponents": failedComponents, "message": message, }, nil } func (a *App) backupStopRuntimeForMaintenance() { if a.browserMgr != nil { a.browserMgr.Mutex.Lock() for _, cmd := range a.browserMgr.BrowserProcesses { if cmd != nil && cmd.Process != nil { _ = a.stopProcessCmd(cmd) } } a.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) a.browserMgr.Mutex.Unlock() } if a.xrayMgr != nil { a.xrayMgr.StopAll() } a.clearProfileXrayBridges() if a.singboxMgr != nil { a.singboxMgr.StopAll() } if a.speedScheduler != nil { a.speedScheduler.Stop() a.speedScheduler = nil } } func (a *App) backupReloadAfterMutation() error { if err := a.ReloadConfig(); err != nil { return err } if a.browserMgr != nil { a.browserMgr.Config = a.config a.browserMgr.Mutex.Lock() a.browserMgr.Profiles = make(map[string]*browser.Profile) a.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) a.browserMgr.XrayBridges = make(map[string]*browser.XrayBridge) a.browserMgr.Mutex.Unlock() } if a.xrayMgr != nil { a.xrayMgr.Config = a.config } if a.clashMgr != nil { a.clashMgr.Config = a.config } if a.singboxMgr != nil { a.singboxMgr.Config = a.config } a.migrateToSQLite() if a.browserMgr != nil { a.browserMgr.InitData() } a.autoDetectCores() a.loadProxies() if a.launchCodeSvc != nil { _ = a.launchCodeSvc.LoadAll() } if a.browserMgr != nil { a.browserMgr.CodeProvider = a.launchCodeSvc } if a.browserMgr != nil && a.browserMgr.ProxyDAO != nil { a.speedScheduler = browser.NewProxySpeedScheduler( a.browserMgr.ProxyDAO, func(proxyID string) (bool, int64, string) { r := proxy.SpeedTest(proxyID, a.config.Browser.Proxies, a.xrayMgr, a.singboxMgr, nil) return r.Ok, r.LatencyMs, r.Error }, 5*time.Minute, 5, ) a.speedScheduler.Start() } return nil } func (a *App) backupResolveDBPath(cfg *config.Config) string { if cfg == nil { return a.resolveAppPath("data/app.db") } path := strings.TrimSpace(cfg.Database.SQLite.Path) if path == "" { path = "data/app.db" } return a.resolveAppPath(path) } func (a *App) backupResolveUserDataRoot(cfg *config.Config) string { if cfg == nil { return a.resolveAppPath("data") } root := strings.TrimSpace(cfg.Browser.UserDataRoot) if root == "" { root = "data" } return a.resolveAppPath(root) } func (a *App) backupClearBusinessTables() error { if a.db == nil || a.db.GetConn() == nil { return fmt.Errorf("数据库未初始化") } tx, err := a.db.GetConn().Begin() if err != nil { return fmt.Errorf("开启事务失败: %w", err) } defer tx.Rollback() tables := []string{"launch_codes", "browser_profiles", "browser_proxies", "browser_cores", "browser_bookmarks", "browser_groups"} for _, table := range tables { if _, err := tx.Exec("DELETE FROM " + table); err != nil && !backupIsNoSuchTableError(err) { return fmt.Errorf("清空数据表失败(%s): %w", table, err) } } _, _ = tx.Exec(`DELETE FROM sqlite_sequence WHERE name IN ('browser_bookmarks')`) return tx.Commit() } func backupWritePackageZip(zipPath string, scope backup.Scope, manifest backup.Manifest, emitProgress func(phase string, progress int, message string, meta *backupProgressMeta)) (int, int, int, error) { emit := func(phase string, progress int, message string, meta *backupProgressMeta) { if emitProgress != nil { emitProgress(phase, progress, message, meta) } } if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil { return 0, 0, 0, fmt.Errorf("创建导出目录失败: %w", err) } emit("writing", 18, "正在创建导出文件...", nil) tmpPath := zipPath + ".tmp" f, err := os.Create(tmpPath) if err != nil { return 0, 0, 0, fmt.Errorf("创建导出文件失败: %w", err) } w := zip.NewWriter(f) includedEntries := 0 skippedEntries := 0 fileCount := 0 writeErr := func() error { emit("writing", 20, "正在写入备份清单...", nil) manifestData, err := json.MarshalIndent(manifest, "", " ") if err != nil { return err } mw, err := w.Create("manifest.json") if err != nil { return err } if _, err := mw.Write(manifestData); err != nil { return err } fileCount++ totalEntries := len(scope.Entries) if totalEntries == 0 { emit("writing", 90, "没有可导出的目录条目", nil) } for i, entry := range scope.Entries { meta := &backupProgressMeta{ ComponentID: entry.ID, ComponentName: backupResolveEntryComponentName(entry), EntryIndex: i + 1, EntryTotal: totalEntries, } startProgress := 20 + int(float64(i)/float64(totalEntries)*70) emit("writing", startProgress, fmt.Sprintf("开始处理组件 %d/%d:%s", i+1, totalEntries, meta.ComponentName), meta) info, err := os.Stat(entry.SourcePath) if err != nil { if os.IsNotExist(err) && !entry.Required { skippedEntries++ progress := 20 + int(float64(i+1)/float64(totalEntries)*70) emit("writing", progress, fmt.Sprintf("组件跳过:%s(源路径不存在)", meta.ComponentName), meta) continue } return fmt.Errorf("读取导出源失败(%s): %w", entry.ID, err) } entryAddedFiles := 0 if info.IsDir() { n, err := backupZipAddDir(w, entry.SourcePath, entry.ArchivePath, zipPath) if err != nil { return fmt.Errorf("写入目录失败(%s): %w", entry.ID, err) } fileCount += n entryAddedFiles = n } else { if backupSamePath(entry.SourcePath, zipPath) { skippedEntries++ progress := 20 + int(float64(i+1)/float64(totalEntries)*70) emit("writing", progress, fmt.Sprintf("组件跳过:%s(导出文件本身)", meta.ComponentName), meta) continue } if err := backupZipAddFile(w, entry.SourcePath, strings.TrimSuffix(entry.ArchivePath, "/")); err != nil { return fmt.Errorf("写入文件失败(%s): %w", entry.ID, err) } fileCount++ entryAddedFiles = 1 } includedEntries++ progress := 20 + int(float64(i+1)/float64(totalEntries)*70) emit("writing", progress, fmt.Sprintf("组件完成:%s(新增 %d 个文件)", meta.ComponentName, entryAddedFiles), meta) } return nil }() closeErr := w.Close() fileCloseErr := f.Close() if writeErr != nil { emit("error", 100, writeErr.Error(), nil) _ = os.Remove(tmpPath) return 0, 0, 0, writeErr } if closeErr != nil { emit("error", 100, closeErr.Error(), nil) _ = os.Remove(tmpPath) return 0, 0, 0, closeErr } if fileCloseErr != nil { emit("error", 100, fileCloseErr.Error(), nil) _ = os.Remove(tmpPath) return 0, 0, 0, fileCloseErr } if err := os.Rename(tmpPath, zipPath); err != nil { emit("error", 100, err.Error(), nil) _ = os.Remove(tmpPath) return 0, 0, 0, fmt.Errorf("写入导出文件失败: %w", err) } emit("done", 100, "导出完成", nil) return includedEntries, skippedEntries, fileCount, nil } func backupResolveEntryComponentName(entry backup.ScopeEntry) string { if desc := strings.TrimSpace(entry.Description); desc != "" { return desc } if entry.ID != "" { return entry.ID } switch entry.Category { case backup.CategorySystemConfig: return "系统配置" case backup.CategoryAppData: return "应用数据" case backup.CategoryBrowserData: return "浏览器数据" case backup.CategoryCoreData: return "内核数据" case backup.CategoryLogs: return "日志数据" default: return "未知组件" } } func backupZipAddDir(w *zip.Writer, srcDir, archiveBase, outputZipPath string) (int, error) { base := strings.TrimSuffix(filepath.ToSlash(strings.TrimSpace(archiveBase)), "/") if base == "" { return 0, fmt.Errorf("archive base 不能为空") } fileCount := 0 err := filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if backupSamePath(path, outputZipPath) { return nil } if d.Type()&os.ModeSymlink != 0 { return nil } rel, err := filepath.Rel(srcDir, path) if err != nil { return err } if rel == "." { return nil } rel = filepath.ToSlash(rel) targetName := base + "/" + rel if d.IsDir() { _, err := w.Create(strings.TrimSuffix(targetName, "/") + "/") return err } if err := backupZipAddFile(w, path, targetName); err != nil { return err } fileCount++ return nil }) return fileCount, err } func backupZipAddFile(w *zip.Writer, srcFile, archivePath string) error { info, err := os.Stat(srcFile) if err != nil { return err } if info.IsDir() { return fmt.Errorf("不支持将目录按文件写入: %s", srcFile) } header, err := zip.FileInfoHeader(info) if err != nil { return err } header.Name = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(archivePath)), "/") header.Method = zip.Deflate if header.Name == "" { return fmt.Errorf("archivePath 不能为空") } writer, err := w.CreateHeader(header) if err != nil { return err } in, err := os.Open(srcFile) if err != nil { return err } defer in.Close() _, err = io.Copy(writer, in) return err } func backupExtractAndValidate(zipPath string) (string, backup.Manifest, error) { tmpDir, err := os.MkdirTemp("", "ant-chrome-import-*") if err != nil { return "", backup.Manifest{}, err } if err := unzipTo(zipPath, tmpDir); err != nil { _ = os.RemoveAll(tmpDir) return "", backup.Manifest{}, fmt.Errorf("解压备份包失败: %w", err) } manifestPath := filepath.Join(tmpDir, "manifest.json") data, err := os.ReadFile(manifestPath) if err != nil { _ = os.RemoveAll(tmpDir) return "", backup.Manifest{}, fmt.Errorf("备份包缺少 manifest.json") } var manifest backup.Manifest if err := json.Unmarshal(data, &manifest); err != nil { _ = os.RemoveAll(tmpDir) return "", backup.Manifest{}, fmt.Errorf("manifest.json 解析失败: %w", err) } if manifest.Format != backup.PackageFormat { _ = os.RemoveAll(tmpDir) return "", backup.Manifest{}, fmt.Errorf("不支持的备份格式: %s", manifest.Format) } if manifest.ManifestVersion != backup.ManifestVersion { _ = os.RemoveAll(tmpDir) return "", backup.Manifest{}, fmt.Errorf("不支持的 manifest 版本: %d", manifest.ManifestVersion) } if _, err := os.Stat(filepath.Join(tmpDir, "payload")); err != nil { _ = os.RemoveAll(tmpDir) return "", backup.Manifest{}, fmt.Errorf("备份包缺少 payload 目录") } return tmpDir, manifest, nil } func backupLoadIncomingConfig(payloadRoot string) (*config.Config, bool, error) { cfgPath := filepath.Join(payloadRoot, "system", "config.yaml") if _, err := os.Stat(cfgPath); err != nil { if os.IsNotExist(err) { return nil, false, nil } return nil, false, err } cfg, err := config.Load(cfgPath) if err != nil { return nil, false, err } return cfg, true, nil } func backupDetectPresentManifestEntries(extractRoot string, manifest backup.Manifest) map[string]backup.ManifestEntry { result := make(map[string]backup.ManifestEntry, len(manifest.Entries)) for _, entry := range manifest.Entries { id := strings.TrimSpace(entry.ID) if id == "" { continue } archivePath := strings.TrimSpace(strings.TrimSuffix(entry.ArchivePath, "/")) if archivePath == "" { continue } absPath := filepath.Join(extractRoot, filepath.FromSlash(archivePath)) if _, err := os.Stat(absPath); err == nil { result[id] = entry } } return result } func backupResolveManifestComponentName(entry backup.ManifestEntry) string { if desc := strings.TrimSpace(entry.Description); desc != "" { return desc } if id := strings.TrimSpace(entry.ID); id != "" { return id } return "未知模块" } func (a *App) backupApplyIncomingConfig(incoming *config.Config, resetFirst bool) error { if incoming == nil { return nil } current := a.config if current == nil { current = config.DefaultConfig() } var target *config.Config if resetFirst { cloned := *incoming target = &cloned } else { target = backupMergeConfig(current, incoming) } target.Database = current.Database target.App.MaxProfileLimit = current.App.MaxProfileLimit target.App.UsedCDKeys = append([]string{}, current.App.UsedCDKeys...) if err := target.Save(a.resolveAppPath("config.yaml")); err != nil { return fmt.Errorf("保存导入配置失败: %w", err) } a.config = target a.applyRuntimeConfig(target.Runtime) return nil } func backupMergeConfig(current, incoming *config.Config) *config.Config { if current == nil { cp := *incoming return &cp } if incoming == nil { cp := *current return &cp } merged := *current if strings.TrimSpace(merged.App.Name) == "" { merged.App.Name = incoming.App.Name } merged.Browser.DefaultBookmarks = backupMergeBookmarks(merged.Browser.DefaultBookmarks, incoming.Browser.DefaultBookmarks) merged.Browser.Cores = backupMergeCores(merged.Browser.Cores, incoming.Browser.Cores) merged.Browser.Proxies = backupMergeProxies(merged.Browser.Proxies, incoming.Browser.Proxies) merged.Browser.Profiles = backupMergeProfiles(merged.Browser.Profiles, incoming.Browser.Profiles) return &merged } func (a *App) backupMergeProxiesFile(payloadRoot string, resetFirst bool, stats *backupMergeStats) error { srcPath := filepath.Join(payloadRoot, "system", "proxies.yaml") dstPath := a.resolveAppPath("proxies.yaml") if _, err := os.Stat(srcPath); err != nil { if os.IsNotExist(err) { if resetFirst { _ = os.Remove(dstPath) } return nil } return err } if resetFirst { return backupCopyFile(srcPath, dstPath) } incoming, err := config.LoadProxies(srcPath) if err != nil { return err } current, err := config.LoadProxies(dstPath) if err != nil { return err } merged := append([]config.BrowserProxy{}, current...) existingID := make(map[string]struct{}, len(current)) existingCfg := make(map[string]struct{}, len(current)) for _, p := range current { existingID[strings.ToLower(strings.TrimSpace(p.ProxyId))] = struct{}{} existingCfg[strings.ToLower(strings.TrimSpace(p.ProxyConfig))] = struct{}{} } for _, p := range incoming { idKey := strings.ToLower(strings.TrimSpace(p.ProxyId)) cfgKey := strings.ToLower(strings.TrimSpace(p.ProxyConfig)) if _, ok := existingID[idKey]; ok { stats.Skipped++ continue } if cfgKey != "" { if _, ok := existingCfg[cfgKey]; ok { stats.Skipped++ continue } } merged = append(merged, p) existingID[idKey] = struct{}{} if cfgKey != "" { existingCfg[cfgKey] = struct{}{} } stats.Imported++ } return config.SaveProxies(dstPath, merged) } func backupFindDatabaseFile(payloadRoot string) string { candidates := []string{ filepath.Join(payloadRoot, "app", "database", "app.db"), filepath.Join(payloadRoot, "app", "data", "app.db"), } for _, p := range candidates { if st, err := os.Stat(p); err == nil && !st.IsDir() { return p } } return "" } func (a *App) backupMergeDatabaseFromSource(srcDBPath string, resetFirst bool, stats *backupMergeStats) error { if a.db == nil || a.db.GetConn() == nil { return fmt.Errorf("数据库未初始化") } tx, err := a.db.GetConn().Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`ATTACH DATABASE ? AS src`, srcDBPath); err != nil { return fmt.Errorf("挂载备份数据库失败: %w", err) } defer tx.Exec(`DETACH DATABASE src`) mergeTables := []struct { name string insertAll string insertSafe string }{ { name: "browser_groups", insertAll: `INSERT INTO browser_groups (group_id, group_name, parent_id, sort_order, created_at, updated_at) SELECT group_id, group_name, parent_id, sort_order, created_at, updated_at FROM src.browser_groups`, insertSafe: `INSERT INTO browser_groups (group_id, group_name, parent_id, sort_order, created_at, updated_at) SELECT s.group_id, s.group_name, s.parent_id, s.sort_order, s.created_at, s.updated_at FROM src.browser_groups s WHERE NOT EXISTS ( SELECT 1 FROM browser_groups t WHERE t.group_id = s.group_id OR (t.parent_id = s.parent_id AND lower(t.group_name) = lower(s.group_name)) )`, }, { name: "browser_cores", insertAll: `INSERT INTO browser_cores (core_id, core_name, core_path, is_default, sort_order, created_at) SELECT core_id, core_name, core_path, is_default, sort_order, created_at FROM src.browser_cores`, insertSafe: `INSERT INTO browser_cores (core_id, core_name, core_path, is_default, sort_order, created_at) SELECT s.core_id, s.core_name, s.core_path, s.is_default, s.sort_order, s.created_at FROM src.browser_cores s WHERE NOT EXISTS ( SELECT 1 FROM browser_cores t WHERE t.core_id = s.core_id OR lower(t.core_path) = lower(s.core_path) )`, }, { name: "browser_proxies", insertAll: `INSERT INTO browser_proxies (proxy_id, proxy_name, proxy_config, dns_servers, group_name, source_id, source_url, source_name_prefix, source_auto_refresh, source_refresh_interval_m, source_last_refresh_at, last_latency_ms, last_test_ok, last_tested_at, last_ip_health_json, sort_order, created_at) SELECT proxy_id, proxy_name, proxy_config, dns_servers, COALESCE(group_name,''), COALESCE(source_id,''), COALESCE(source_url,''), COALESCE(source_name_prefix,''), COALESCE(source_auto_refresh,0), COALESCE(source_refresh_interval_m,0), COALESCE(source_last_refresh_at,''), COALESCE(last_latency_ms,-1), COALESCE(last_test_ok,0), COALESCE(last_tested_at,''), COALESCE(last_ip_health_json,''), sort_order, created_at FROM src.browser_proxies`, insertSafe: `INSERT INTO browser_proxies (proxy_id, proxy_name, proxy_config, dns_servers, group_name, source_id, source_url, source_name_prefix, source_auto_refresh, source_refresh_interval_m, source_last_refresh_at, last_latency_ms, last_test_ok, last_tested_at, last_ip_health_json, sort_order, created_at) SELECT s.proxy_id, s.proxy_name, s.proxy_config, s.dns_servers, COALESCE(s.group_name,''), COALESCE(s.source_id,''), COALESCE(s.source_url,''), COALESCE(s.source_name_prefix,''), COALESCE(s.source_auto_refresh,0), COALESCE(s.source_refresh_interval_m,0), COALESCE(s.source_last_refresh_at,''), COALESCE(s.last_latency_ms,-1), COALESCE(s.last_test_ok,0), COALESCE(s.last_tested_at,''), COALESCE(s.last_ip_health_json,''), s.sort_order, s.created_at FROM src.browser_proxies s WHERE NOT EXISTS ( SELECT 1 FROM browser_proxies t WHERE t.proxy_id = s.proxy_id OR lower(t.proxy_config) = lower(s.proxy_config) )`, }, { name: "browser_profiles", insertAll: `INSERT INTO browser_profiles (profile_id, profile_name, user_data_dir, core_id, fingerprint_args, proxy_id, proxy_config, launch_args, tags, keywords, group_id, created_at, updated_at) SELECT profile_id, profile_name, user_data_dir, core_id, fingerprint_args, proxy_id, proxy_config, launch_args, tags, keywords, COALESCE(group_id,''), created_at, updated_at FROM src.browser_profiles`, insertSafe: `INSERT INTO browser_profiles (profile_id, profile_name, user_data_dir, core_id, fingerprint_args, proxy_id, proxy_config, launch_args, tags, keywords, group_id, created_at, updated_at) SELECT s.profile_id, s.profile_name, s.user_data_dir, s.core_id, s.fingerprint_args, s.proxy_id, s.proxy_config, s.launch_args, s.tags, s.keywords, COALESCE(s.group_id,''), s.created_at, s.updated_at FROM src.browser_profiles s WHERE NOT EXISTS ( SELECT 1 FROM browser_profiles t WHERE t.profile_id = s.profile_id OR lower(t.user_data_dir) = lower(s.user_data_dir) )`, }, { name: "browser_bookmarks", insertAll: `INSERT INTO browser_bookmarks (name, url, sort_order) SELECT name, url, sort_order FROM src.browser_bookmarks`, insertSafe: `INSERT INTO browser_bookmarks (name, url, sort_order) SELECT s.name, s.url, s.sort_order FROM src.browser_bookmarks s WHERE NOT EXISTS ( SELECT 1 FROM browser_bookmarks t WHERE lower(t.url) = lower(s.url) )`, }, { name: "launch_codes", insertAll: `INSERT INTO launch_codes (profile_id, code, created_at, updated_at) SELECT profile_id, code, created_at, updated_at FROM src.launch_codes`, insertSafe: `INSERT INTO launch_codes (profile_id, code, created_at, updated_at) SELECT s.profile_id, s.code, s.created_at, s.updated_at FROM src.launch_codes s WHERE NOT EXISTS ( SELECT 1 FROM launch_codes t WHERE t.profile_id = s.profile_id OR t.code = s.code )`, }, } for _, item := range mergeTables { exists, err := backupSrcTableExists(tx, item.name) if err != nil { return err } if !exists { continue } total, err := backupCountRows(tx, "src."+item.name) if err != nil { return err } if total == 0 { continue } sqlText := item.insertAll if !resetFirst { sqlText = item.insertSafe } res, err := tx.Exec(sqlText) if err != nil { return fmt.Errorf("导入数据表失败(%s): %w", item.name, err) } affected, _ := res.RowsAffected() inserted := int(affected) if inserted < 0 { inserted = total } stats.Imported += inserted if !resetFirst && total > inserted { stats.Skipped += total - inserted } } return tx.Commit() } func (a *App) backupImportFileTrees(payloadRoot string, incomingCfg *config.Config, resetFirst bool, stats *backupMergeStats, onIssue func(componentID, componentName string, err error)) { report := func(componentID, componentName string, err error) { if onIssue != nil && err != nil { onIssue(componentID, componentName, err) } } appDataSrc := filepath.Join(payloadRoot, "app", "data") appDataDst := a.resolveAppPath("data") dbPath := a.backupResolveDBPath(a.config) keepDB := map[string]struct{}{ backupNormalizePath(dbPath): {}, backupNormalizePath(dbPath + "-wal"): {}, backupNormalizePath(dbPath + "-shm"): {}, } if backupPathExists(appDataSrc) { if resetFirst { if err := backupRemoveContentsExcept(appDataDst, keepDB); err != nil { report("app_data_root", "应用数据目录(含数据库、快照及默认浏览器数据)", err) } else if err := backupSyncDir(appDataSrc, appDataDst, true, stats, backupShouldSkipAppDBFile); err != nil { report("app_data_root", "应用数据目录(含数据库、快照及默认浏览器数据)", err) } } else { if err := backupSyncDir(appDataSrc, appDataDst, false, stats, backupShouldSkipAppDBFile); err != nil { report("app_data_root", "应用数据目录(含数据库、快照及默认浏览器数据)", err) } } } userDataSrc := filepath.Join(payloadRoot, "browser", "user-data") userDataDst := a.backupResolveUserDataRoot(a.config) if backupPathExists(userDataSrc) { if resetFirst { _ = os.RemoveAll(userDataDst) if err := os.MkdirAll(userDataDst, 0755); err != nil { report("browser_user_data_root", "浏览器用户数据根目录(若与 data 重合则自动去重)", err) } else if err := backupSyncDir(userDataSrc, userDataDst, true, stats, nil); err != nil { report("browser_user_data_root", "浏览器用户数据根目录(若与 data 重合则自动去重)", err) } } else { if err := backupSyncDir(userDataSrc, userDataDst, false, stats, nil); err != nil { report("browser_user_data_root", "浏览器用户数据根目录(若与 data 重合则自动去重)", err) } } } chromeSrc := filepath.Join(payloadRoot, "browser", "cores", "chrome") chromeDst := a.resolveAppPath("chrome") if backupPathExists(chromeSrc) { if resetFirst { _ = os.RemoveAll(chromeDst) if err := os.MkdirAll(chromeDst, 0755); err != nil { report("browser_core_root", "默认内核目录", err) } else if err := backupSyncDir(chromeSrc, chromeDst, true, stats, nil); err != nil { report("browser_core_root", "默认内核目录", err) } } else { if err := backupSyncDir(chromeSrc, chromeDst, false, stats, nil); err != nil { report("browser_core_root", "默认内核目录", err) } } } externalSrcRoot := filepath.Join(payloadRoot, "browser", "cores", "external") if backupPathExists(externalSrcRoot) { sourceExternal := make([]string, 0) entries, err := os.ReadDir(externalSrcRoot) if err != nil { report("browser_core_external", "额外内核目录(来自配置 cores)", err) return } for _, entry := range entries { if !entry.IsDir() { continue } sourceExternal = append(sourceExternal, entry.Name()) } sort.Strings(sourceExternal) if incomingCfg == nil { for _, folder := range sourceExternal { componentID := "browser_core_external_" + folder report(componentID, "额外内核目录(来自配置 cores)", fmt.Errorf("缺少可用配置,无法映射目标路径")) } return } targetExternal := a.backupCollectExternalCorePaths(incomingCfg) for i, folder := range sourceExternal { src := filepath.Join(externalSrcRoot, folder) componentID := "browser_core_external_" + folder if i >= len(targetExternal) { stats.Skipped++ report(componentID, "额外内核目录(来自配置 cores)", fmt.Errorf("目标配置缺失,无法导入该外部内核目录")) continue } dst := targetExternal[i] if resetFirst { _ = os.RemoveAll(dst) if err := os.MkdirAll(dst, 0755); err != nil { report(componentID, "额外内核目录(来自配置 cores)", err) continue } if err := backupSyncDir(src, dst, true, stats, nil); err != nil { report(componentID, "额外内核目录(来自配置 cores)", err) continue } } else { if err := backupSyncDir(src, dst, false, stats, nil); err != nil { report(componentID, "额外内核目录(来自配置 cores)", err) continue } } } } } func (a *App) backupCollectExternalCorePaths(cfg *config.Config) []string { if cfg == nil { return nil } defaultChromeRoot := a.resolveAppPath("chrome") seen := map[string]struct{}{} result := make([]string, 0) for _, core := range cfg.Browser.Cores { p := strings.TrimSpace(core.CorePath) if p == "" { continue } abs := a.resolveAppPath(p) if backupPathWithin(abs, defaultChromeRoot) { continue } norm := backupNormalizePath(abs) if _, ok := seen[norm]; ok { continue } seen[norm] = struct{}{} result = append(result, abs) } sort.Strings(result) return result } func backupSyncDir(src, dst string, overwrite bool, stats *backupMergeStats, shouldSkip func(rel string) bool) error { if !backupPathExists(src) { return nil } if err := os.MkdirAll(dst, 0755); err != nil { return err } return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.Type()&os.ModeSymlink != 0 { stats.Skipped++ if d.IsDir() { return filepath.SkipDir } return nil } rel, err := filepath.Rel(src, path) if err != nil { return err } if rel == "." { return nil } rel = filepath.ToSlash(rel) if shouldSkip != nil && shouldSkip(rel) { if d.IsDir() { return filepath.SkipDir } stats.Skipped++ return nil } target := filepath.Join(dst, filepath.FromSlash(rel)) if d.IsDir() { return os.MkdirAll(target, 0755) } if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } if overwrite { if err := backupCopyFile(path, target); err != nil { return err } stats.Imported++ return nil } if _, err := os.Stat(target); os.IsNotExist(err) { if err := backupCopyFile(path, target); err != nil { return err } stats.Imported++ return nil } else if err != nil { return err } same, err := backupFilesSame(path, target) if err != nil { return err } if same { stats.Skipped++ } else { stats.Conflicts++ } return nil }) } func backupCopyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() info, err := in.Stat() if err != nil { return err } tmpPath := dst + ".tmp" out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() _ = os.Remove(tmpPath) return err } if err := out.Close(); err != nil { _ = os.Remove(tmpPath) return err } if err := os.Rename(tmpPath, dst); err != nil { _ = os.Remove(tmpPath) return err } return nil } func backupFilesSame(a, b string) (bool, error) { ainfo, err := os.Stat(a) if err != nil { return false, err } binfo, err := os.Stat(b) if err != nil { return false, err } if ainfo.Size() != binfo.Size() { return false, nil } ah, err := backupSHA256File(a) if err != nil { return false, err } bh, err := backupSHA256File(b) if err != nil { return false, err } return ah == bh, nil } func backupSHA256File(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } func backupShouldSkipAppDBFile(rel string) bool { r := strings.TrimSpace(filepath.ToSlash(rel)) return r == "app.db" || r == "app.db-wal" || r == "app.db-shm" } func backupRemoveContentsExcept(dir string, keep map[string]struct{}) error { dir = strings.TrimSpace(dir) if dir == "" { return nil } if err := os.MkdirAll(dir, 0755); err != nil { return err } entries, err := os.ReadDir(dir) if err != nil { return err } for _, entry := range entries { p := filepath.Join(dir, entry.Name()) if backupPathInSet(p, keep) { continue } if err := os.RemoveAll(p); err != nil { return err } } return nil } func backupPathInSet(path string, set map[string]struct{}) bool { if len(set) == 0 { return false } _, ok := set[backupNormalizePath(path)] return ok } func backupNormalizePath(path string) string { return strings.ToLower(filepath.Clean(strings.TrimSpace(path))) } func backupEnsureZipSuffix(path string) string { if strings.EqualFold(filepath.Ext(path), ".zip") { return path } return path + ".zip" } func backupPathExists(path string) bool { _, err := os.Stat(path) return err == nil } func backupSamePath(a, b string) bool { return backupNormalizePath(a) == backupNormalizePath(b) } func backupPathWithin(path, root string) bool { p := backupNormalizePath(path) r := backupNormalizePath(root) if p == r { return true } if !strings.HasSuffix(r, string(filepath.Separator)) { r += string(filepath.Separator) } return strings.HasPrefix(p, r) } func backupIsNoSuchTableError(err error) bool { if err == nil { return false } return strings.Contains(strings.ToLower(err.Error()), "no such table") } func backupUniqueNonEmpty(list []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(list)) for _, item := range list { item = strings.TrimSpace(item) if item == "" { continue } key := backupNormalizePath(item) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, item) } return out } func backupUnionStrings(a, b []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(a)+len(b)) for _, item := range append(append([]string{}, a...), b...) { item = strings.TrimSpace(item) if item == "" { continue } key := strings.ToLower(item) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, item) } return out } func backupMergeBookmarks(a, b []config.BrowserBookmark) []config.BrowserBookmark { seen := map[string]struct{}{} out := make([]config.BrowserBookmark, 0, len(a)+len(b)) appendOne := func(item config.BrowserBookmark) { urlKey := strings.ToLower(strings.TrimSpace(item.URL)) if urlKey == "" { return } if _, ok := seen[urlKey]; ok { return } seen[urlKey] = struct{}{} out = append(out, item) } for _, item := range a { appendOne(item) } for _, item := range b { appendOne(item) } return out } func backupMergeCores(a, b []config.BrowserCore) []config.BrowserCore { seenID := map[string]struct{}{} seenPath := map[string]struct{}{} out := make([]config.BrowserCore, 0, len(a)+len(b)) appendOne := func(item config.BrowserCore) { idKey := strings.ToLower(strings.TrimSpace(item.CoreId)) pathKey := strings.ToLower(strings.TrimSpace(item.CorePath)) if idKey == "" && pathKey == "" { return } if idKey != "" { if _, ok := seenID[idKey]; ok { return } } if pathKey != "" { if _, ok := seenPath[pathKey]; ok { return } } if idKey != "" { seenID[idKey] = struct{}{} } if pathKey != "" { seenPath[pathKey] = struct{}{} } out = append(out, item) } for _, item := range a { appendOne(item) } for _, item := range b { appendOne(item) } return out } func backupMergeProxies(a, b []config.BrowserProxy) []config.BrowserProxy { seenID := map[string]struct{}{} seenCfg := map[string]struct{}{} out := make([]config.BrowserProxy, 0, len(a)+len(b)) appendOne := func(item config.BrowserProxy) { idKey := strings.ToLower(strings.TrimSpace(item.ProxyId)) cfgKey := strings.ToLower(strings.TrimSpace(item.ProxyConfig)) if idKey == "" && cfgKey == "" { return } if idKey != "" { if _, ok := seenID[idKey]; ok { return } } if cfgKey != "" { if _, ok := seenCfg[cfgKey]; ok { return } } if idKey != "" { seenID[idKey] = struct{}{} } if cfgKey != "" { seenCfg[cfgKey] = struct{}{} } out = append(out, item) } for _, item := range a { appendOne(item) } for _, item := range b { appendOne(item) } return out } func backupMergeProfiles(a, b []config.BrowserProfileConfig) []config.BrowserProfileConfig { seenID := map[string]struct{}{} seenDir := map[string]struct{}{} out := make([]config.BrowserProfileConfig, 0, len(a)+len(b)) appendOne := func(item config.BrowserProfileConfig) { idKey := strings.ToLower(strings.TrimSpace(item.ProfileId)) dirKey := strings.ToLower(strings.TrimSpace(item.UserDataDir)) if idKey == "" && dirKey == "" { return } if idKey != "" { if _, ok := seenID[idKey]; ok { return } } if dirKey != "" { if _, ok := seenDir[dirKey]; ok { return } } if idKey != "" { seenID[idKey] = struct{}{} } if dirKey != "" { seenDir[dirKey] = struct{}{} } out = append(out, item) } for _, item := range a { appendOne(item) } for _, item := range b { appendOne(item) } return out } func backupSrcTableExists(tx *sql.Tx, table string) (bool, error) { var cnt int err := tx.QueryRow(`SELECT COUNT(1) FROM src.sqlite_master WHERE type='table' AND name=?`, table).Scan(&cnt) if err != nil { return false, err } return cnt > 0, nil } func backupCountRows(tx *sql.Tx, tableName string) (int, error) { var cnt int row := tx.QueryRow("SELECT COUNT(1) FROM " + tableName) if err := row.Scan(&cnt); err != nil { return 0, err } return cnt, nil }