Files
Ant-Browser/backend/app_profile_package_api.go

634 lines
18 KiB
Go

package backend
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"ant-chrome/backend/internal/browser"
"github.com/google/uuid"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const profilePackageFormat = "ant-chrome-profile-package"
type ProfilePackageManifest struct {
Format string `json:"format"`
Version int `json:"version"`
ExportedAt string `json:"exportedAt"`
ProfileCount int `json:"profileCount"`
}
type ProfilePackageExportResult struct {
Cancelled bool `json:"cancelled"`
ZipPath string `json:"zipPath"`
ProfileCount int `json:"profileCount"`
FileCount int `json:"fileCount"`
Message string `json:"message"`
}
type ProfilePackageImportResult struct {
Cancelled bool `json:"cancelled"`
ImportedCount int `json:"importedCount"`
ProfileMappings map[string]string `json:"profileMappings"`
Warnings []string `json:"warnings"`
Message string `json:"message"`
}
type preparedProfilePackageImport struct {
Profile browser.Profile
OldProfileID string
FinalDir string
StagingDir string
HasUserData bool
}
// BrowserProfilePackageExport 导出选中的实例配置和浏览器用户数据目录。
func (a *App) BrowserProfilePackageExport(profileIds []string) (ProfilePackageExportResult, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
ids := normalizeProfilePackageIDs(profileIds)
if len(ids) == 0 {
return ProfilePackageExportResult{}, fmt.Errorf("请选择要导出的实例")
}
if a.ctx == nil {
return ProfilePackageExportResult{}, fmt.Errorf("应用上下文未初始化")
}
profiles, err := a.collectProfilesForPackage(ids)
if err != nil {
return ProfilePackageExportResult{}, err
}
defaultName := fmt.Sprintf("ant-chrome-profile-package-%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 ProfilePackageExportResult{}, fmt.Errorf("打开保存对话框失败: %w", err)
}
if strings.TrimSpace(savePath) == "" {
return ProfilePackageExportResult{Cancelled: true, Message: "已取消导出"}, nil
}
savePath = ensureZipSuffix(savePath)
fileCount, err := a.writeProfilePackage(savePath, profiles)
if err != nil {
return ProfilePackageExportResult{}, err
}
return ProfilePackageExportResult{
Cancelled: false,
ZipPath: savePath,
ProfileCount: len(profiles),
FileCount: fileCount,
Message: "导出完成",
}, nil
}
// BrowserProfilePackageImport 导入实例包,冲突时始终生成新实例和新目录。
func (a *App) BrowserProfilePackageImport() (ProfilePackageImportResult, error) {
a.maintenanceMu.Lock()
defer a.maintenanceMu.Unlock()
if a.ctx == nil {
return ProfilePackageImportResult{}, fmt.Errorf("应用上下文未初始化")
}
zipPath, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "导入实例",
Filters: []wailsruntime.FileFilter{
{DisplayName: "ZIP 文件 (*.zip)", Pattern: "*.zip"},
},
})
if err != nil {
return ProfilePackageImportResult{}, fmt.Errorf("打开文件对话框失败: %w", err)
}
if strings.TrimSpace(zipPath) == "" {
return ProfilePackageImportResult{Cancelled: true, Message: "已取消导入"}, nil
}
return a.importProfilePackageFromPath(zipPath)
}
func (a *App) collectProfilesForPackage(profileIds []string) ([]browser.Profile, error) {
a.browserMgr.InitData()
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
profiles := make([]browser.Profile, 0, len(profileIds))
missing := make([]string, 0)
running := make([]string, 0)
for _, id := range profileIds {
profile := a.browserMgr.Profiles[id]
if profile == nil {
missing = append(missing, id)
continue
}
if profile.Running {
running = append(running, profile.ProfileName)
continue
}
copyProfile := *profile
copyProfile.LaunchCode = ""
copyProfile.Running = false
copyProfile.DebugPort = 0
copyProfile.DebugReady = false
copyProfile.Pid = 0
copyProfile.RuntimeWarning = ""
copyProfile.LastError = ""
a.prepareProfileProxyForPackage(&copyProfile)
profiles = append(profiles, copyProfile)
}
if len(missing) > 0 {
return nil, fmt.Errorf("实例不存在: %s", strings.Join(missing, ", "))
}
if len(running) > 0 {
return nil, fmt.Errorf("请先停止实例再导出: %s", strings.Join(running, ", "))
}
return profiles, nil
}
func (a *App) writeProfilePackage(zipPath string, profiles []browser.Profile) (int, error) {
if err := os.MkdirAll(filepath.Dir(zipPath), 0o755); err != nil {
return 0, fmt.Errorf("创建导出目录失败: %w", err)
}
tmpPath := zipPath + ".tmp"
_ = os.Remove(tmpPath)
out, err := os.Create(tmpPath)
if err != nil {
return 0, fmt.Errorf("创建导出文件失败: %w", err)
}
zipWriter := zip.NewWriter(out)
fileCount := 0
writeErr := func() error {
manifest := ProfilePackageManifest{
Format: profilePackageFormat,
Version: 1,
ExportedAt: time.Now().Format(time.RFC3339),
ProfileCount: len(profiles),
}
if err := writeProfilePackageJSON(zipWriter, "manifest.json", manifest); err != nil {
return err
}
fileCount++
if err := writeProfilePackageJSON(zipWriter, "profiles.json", profiles); err != nil {
return err
}
fileCount++
for i := range profiles {
profile := &profiles[i]
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
if _, err := os.Stat(userDataDir); err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("读取用户数据目录失败: %w", err)
}
added, err := writeProfilePackageDir(zipWriter, userDataDir, "user-data/"+profile.ProfileId)
if err != nil {
return fmt.Errorf("打包用户数据失败 [%s]: %w", profile.ProfileName, err)
}
fileCount += added
}
return nil
}()
closeZipErr := zipWriter.Close()
closeFileErr := out.Close()
if writeErr != nil {
_ = os.Remove(tmpPath)
return 0, writeErr
}
if closeZipErr != nil {
_ = os.Remove(tmpPath)
return 0, closeZipErr
}
if closeFileErr != nil {
_ = os.Remove(tmpPath)
return 0, closeFileErr
}
if err := os.Rename(tmpPath, zipPath); err != nil {
_ = os.Remove(tmpPath)
return 0, fmt.Errorf("保存导出文件失败: %w", err)
}
return fileCount, nil
}
func (a *App) importProfilePackageFromPath(zipPath string) (ProfilePackageImportResult, error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return ProfilePackageImportResult{}, fmt.Errorf("打开实例包失败: %w", err)
}
defer reader.Close()
var manifest ProfilePackageManifest
if err := readProfilePackageJSON(reader.File, "manifest.json", &manifest); err != nil {
return ProfilePackageImportResult{}, err
}
if manifest.Format != profilePackageFormat || manifest.Version != 1 {
return ProfilePackageImportResult{}, fmt.Errorf("不支持的实例包格式")
}
var profiles []browser.Profile
if err := readProfilePackageJSON(reader.File, "profiles.json", &profiles); err != nil {
return ProfilePackageImportResult{}, err
}
if len(profiles) == 0 {
return ProfilePackageImportResult{}, fmt.Errorf("实例包为空")
}
a.browserMgr.InitData()
now := time.Now().Format(time.RFC3339)
mappings := make(map[string]string, len(profiles))
warnings := make([]string, 0)
prepared := make([]preparedProfilePackageImport, 0, len(profiles))
batchID := uuid.NewString()
stagingRoot := a.profilePackageImportStagingRoot(batchID)
committedDirs := make([]string, 0, len(profiles))
committedProfiles := make([]string, 0, len(profiles))
committed := false
defer func() {
_ = os.RemoveAll(stagingRoot)
if !committed {
for _, dir := range committedDirs {
_ = os.RemoveAll(dir)
}
if len(committedProfiles) > 0 {
a.browserMgr.Mutex.Lock()
for _, profileID := range committedProfiles {
delete(a.browserMgr.Profiles, profileID)
}
a.browserMgr.Mutex.Unlock()
}
}
}()
for _, source := range profiles {
oldID := strings.TrimSpace(source.ProfileId)
if oldID == "" {
oldID = uuid.NewString()
}
newID := uuid.NewString()
source.ProfileId = newID
source.ProfileName = buildImportedProfileName(source.ProfileName)
source.UserDataDir = newID
source.Running = false
source.DebugPort = 0
source.DebugReady = false
source.Pid = 0
source.RuntimeWarning = ""
source.LastError = ""
source.LaunchCode = ""
source.CreatedAt = now
source.UpdatedAt = now
source.DeletedAt = ""
if warning := a.applyImportedProfileProxyByName(&source); warning != "" {
warnings = append(warnings, fmt.Sprintf("实例「%s」%s", source.ProfileName, warning))
}
profile := &browser.Profile{ProfileId: newID, UserDataDir: newID}
finalDir := a.browserMgr.ResolveUserDataDir(profile)
stagingDir := filepath.Join(stagingRoot, newID)
hasUserData, err := a.extractProfileUserDataToDir(reader.File, oldID, stagingDir)
if err != nil {
return ProfilePackageImportResult{}, err
}
if !hasUserData {
warnings = append(warnings, fmt.Sprintf("实例「%s」没有用户数据目录,仅导入配置", source.ProfileName))
}
prepared = append(prepared, preparedProfilePackageImport{
Profile: source,
OldProfileID: oldID,
FinalDir: finalDir,
StagingDir: stagingDir,
HasUserData: hasUserData,
})
mappings[oldID] = newID
}
for _, item := range prepared {
if !item.HasUserData {
continue
}
if err := replaceProfileUserDataDir(item.StagingDir, item.FinalDir); err != nil {
return ProfilePackageImportResult{}, err
}
committedDirs = append(committedDirs, item.FinalDir)
}
a.browserMgr.Mutex.Lock()
for i := range prepared {
profile := &prepared[i].Profile
a.browserMgr.Profiles[profile.ProfileId] = profile
committedProfiles = append(committedProfiles, profile.ProfileId)
if a.launchCodeSvc != nil {
if code, err := a.launchCodeSvc.EnsureCode(profile.ProfileId); err == nil {
profile.LaunchCode = code
}
}
}
a.browserMgr.Mutex.Unlock()
if err := a.browserMgr.SaveProfiles(); err != nil {
return ProfilePackageImportResult{}, err
}
committed = true
return ProfilePackageImportResult{
Cancelled: false,
ImportedCount: len(prepared),
ProfileMappings: mappings,
Warnings: warnings,
Message: "导入完成",
}, nil
}
func (a *App) extractProfileUserDataToDir(files []*zip.File, oldProfileID string, destDir string) (bool, error) {
prefix := "user-data/" + oldProfileID + "/"
hasUserData := false
for _, file := range files {
name := filepath.ToSlash(file.Name)
if !strings.HasPrefix(name, prefix) {
continue
}
rel := strings.TrimPrefix(name, prefix)
if rel == "" {
continue
}
if !hasUserData {
if err := os.RemoveAll(destDir); err != nil {
return false, fmt.Errorf("清理临时用户数据目录失败: %w", err)
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return false, fmt.Errorf("创建临时用户数据目录失败: %w", err)
}
hasUserData = true
}
if err := extractProfilePackageFile(file, destDir, rel); err != nil {
return false, err
}
}
return hasUserData, nil
}
func writeProfilePackageJSON(zipWriter *zip.Writer, name string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
writer, err := zipWriter.Create(name)
if err != nil {
return err
}
_, err = writer.Write(data)
return err
}
func writeProfilePackageDir(zipWriter *zip.Writer, srcDir string, destPrefix string) (int, error) {
count := 0
err := filepath.WalkDir(srcDir, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if rel == "." {
return nil
}
zipName := filepath.ToSlash(filepath.Join(destPrefix, rel))
if entry.IsDir() {
_, err := zipWriter.Create(strings.TrimSuffix(zipName, "/") + "/")
return err
}
writer, err := zipWriter.Create(zipName)
if err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
_, copyErr := io.Copy(writer, file)
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
count++
return nil
})
return count, err
}
func readProfilePackageJSON(files []*zip.File, name string, target any) error {
for _, file := range files {
if filepath.ToSlash(file.Name) != name {
continue
}
reader, err := file.Open()
if err != nil {
return err
}
defer reader.Close()
return json.NewDecoder(reader).Decode(target)
}
return fmt.Errorf("实例包缺少 %s", name)
}
func extractProfilePackageFile(file *zip.File, destDir string, rel string) error {
cleanRel := filepath.Clean(filepath.FromSlash(rel))
if cleanRel == "." || strings.HasPrefix(cleanRel, "..") || filepath.IsAbs(cleanRel) {
return fmt.Errorf("非法路径: %s", rel)
}
target := filepath.Join(destDir, cleanRel)
cleanDest := filepath.Clean(destDir)
cleanTarget := filepath.Clean(target)
if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) {
return fmt.Errorf("非法路径: %s", rel)
}
if file.FileInfo().IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
reader, err := file.Open()
if err != nil {
return err
}
defer reader.Close()
out, err := os.Create(target)
if err != nil {
return err
}
_, copyErr := io.Copy(out, reader)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
func replaceProfileUserDataDir(stagingDir string, finalDir string) error {
if strings.TrimSpace(stagingDir) == "" || strings.TrimSpace(finalDir) == "" {
return fmt.Errorf("用户数据目录不能为空")
}
if err := os.MkdirAll(filepath.Dir(finalDir), 0o755); err != nil {
return fmt.Errorf("创建用户数据父目录失败: %w", err)
}
backupDir := finalDir + ".profile-package-backup-" + uuid.NewString()
finalExisted := false
if _, err := os.Stat(finalDir); err == nil {
finalExisted = true
if err := os.Rename(finalDir, backupDir); err != nil {
return fmt.Errorf("备份现有用户数据目录失败: %w", err)
}
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("检查用户数据目录失败: %w", err)
}
if err := os.Rename(stagingDir, finalDir); err != nil {
if finalExisted {
_ = os.Rename(backupDir, finalDir)
}
return fmt.Errorf("提交用户数据目录失败: %w", err)
}
if finalExisted {
_ = os.RemoveAll(backupDir)
}
return nil
}
func (a *App) profilePackageImportStagingRoot(batchID string) string {
root := "data"
if a.browserMgr != nil && a.browserMgr.Config != nil {
root = strings.TrimSpace(a.browserMgr.Config.Browser.UserDataRoot)
}
if root == "" {
root = "data"
}
if a.browserMgr != nil {
root = a.browserMgr.ResolveRelativePath(root)
} else {
root = a.resolveAppPath(root)
}
return filepath.Join(root, ".imports", strings.TrimSpace(batchID))
}
func normalizeProfilePackageIDs(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
result := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
sort.Strings(result)
return result
}
func ensureZipSuffix(path string) string {
if strings.EqualFold(filepath.Ext(path), ".zip") {
return path
}
return path + ".zip"
}
func buildImportedProfileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "导入实例"
}
return name + "(导入)"
}
func (a *App) prepareProfileProxyForPackage(profile *browser.Profile) {
if profile == nil {
return
}
proxyName := strings.TrimSpace(profile.ProxyBindName)
if proxyName == "" {
if proxy, ok := a.browserMgr.GetProxyByID(profile.ProxyId); ok {
proxyName = strings.TrimSpace(proxy.ProxyName)
}
}
profile.ProxyId = ""
profile.ProxyConfig = ""
profile.ProxyBindSourceID = ""
profile.ProxyBindSourceURL = ""
profile.ProxyBindName = proxyName
profile.ProxyBindUpdatedAt = ""
}
func (a *App) applyImportedProfileProxyByName(profile *browser.Profile) string {
if profile == nil {
return ""
}
proxyName := strings.TrimSpace(profile.ProxyBindName)
profile.ProxyId = ""
profile.ProxyConfig = ""
profile.ProxyBindSourceID = ""
profile.ProxyBindSourceURL = ""
profile.ProxyBindUpdatedAt = ""
if proxyName == "" {
profile.ProxyBindName = ""
return ""
}
proxy, matchCount := a.findProxiesByName(proxyName)
if matchCount == 1 {
browser.BindProfileToProxy(profile, proxy, true)
return ""
}
profile.ProxyBindName = ""
if matchCount == 0 {
return fmt.Sprintf("绑定代理「%s」未找到,已清空绑定", proxyName)
}
return fmt.Sprintf("绑定代理「%s」存在多个同名匹配,已清空绑定", proxyName)
}
func (a *App) findUniqueProxyByName(proxyName string) (browser.Proxy, bool) {
proxy, count := a.findProxiesByName(proxyName)
return proxy, count == 1
}
func (a *App) findProxiesByName(proxyName string) (browser.Proxy, int) {
target := strings.ToLower(strings.TrimSpace(proxyName))
if target == "" {
return browser.Proxy{}, 0
}
proxies := browser.ListProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies)
var hit browser.Proxy
matched := 0
for _, proxy := range proxies {
if strings.ToLower(strings.TrimSpace(proxy.ProxyName)) != target {
continue
}
hit = proxy
matched++
if matched > 1 {
return browser.Proxy{}, matched
}
}
return hit, matched
}