From 3c34485c2608cb0503d50f3b2f4b63373d953ada Mon Sep 17 00:00:00 2001 From: ant-black <1016930479@qq.com> Date: Sun, 28 Jun 2026 23:07:06 +0800 Subject: [PATCH] feat: support browser core archive imports --- backend/app_browser_config_api.go | 103 +++++- backend/app_utils.go | 2 +- backend/internal/browser/core_binary.go | 54 ++- .../internal/browser/download_core_extract.go | 330 ++++++++++++++---- .../internal/browser/download_core_task.go | 6 +- .../coreManagement/CoreDownloadModal.tsx | 4 +- 6 files changed, 427 insertions(+), 72 deletions(-) diff --git a/backend/app_browser_config_api.go b/backend/app_browser_config_api.go index 27698cbf..856cbe0d 100644 --- a/backend/app_browser_config_api.go +++ b/backend/app_browser_config_api.go @@ -5,6 +5,7 @@ import ( "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/logger" "fmt" + "os" "path/filepath" "strings" @@ -86,7 +87,7 @@ func (a *App) BrowserCoreScan() []BrowserCore { return a.browserMgr.ListCores() } -// BrowserCoreImportLocal 选择一个已解压内核目录并直接注册,不下载、不复制文件。 +// BrowserCoreImportLocal 选择一个已解压内核目录或归档文件并注册。 func (a *App) BrowserCoreImportLocal() (*BrowserCore, error) { if a.ctx == nil { return nil, fmt.Errorf("app context is nil") @@ -95,6 +96,104 @@ func (a *App) BrowserCoreImportLocal() (*BrowserCore, error) { return nil, fmt.Errorf("browser manager is nil") } + selectedPath, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: "选择 Chrome 内核归档文件", + Filters: []wailsruntime.FileFilter{ + {DisplayName: "Chrome 内核归档 (" + browser.SupportedCoreArchiveDescription() + ")", Pattern: browser.SupportedCoreArchivePattern()}, + {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, + }, + }) + if err != nil { + return nil, err + } + selectedPath = strings.TrimSpace(selectedPath) + if selectedPath == "" { + return nil, nil + } + + absPath, err := filepath.Abs(selectedPath) + if err != nil { + return nil, err + } + return a.importLocalBrowserCoreArchive(absPath) +} + +func (a *App) importLocalBrowserCoreArchive(archivePath string) (*BrowserCore, error) { + archiveName := strings.TrimSpace(filepath.Base(archivePath)) + coreName := strings.TrimSpace(coreNameFromArchiveName(archiveName)) + if coreName == "" { + coreName = "本地内核" + } + + targetCorePath := filepath.Join("chrome", coreName) + targetDir := a.browserMgr.ResolveRelativePath(targetCorePath) + if _, err := os.Stat(targetDir); err == nil { + return nil, fmt.Errorf("同名内核目录已存在:%s", targetCorePath) + } else if err != nil && !os.IsNotExist(err) { + return nil, err + } + + parentDir := filepath.Dir(targetDir) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return nil, err + } + tempExtractDir, err := os.MkdirTemp(parentDir, coreName+"_import_*") + if err != nil { + return nil, err + } + cleanupTempExtract := true + defer func() { + if cleanupTempExtract { + _ = os.RemoveAll(tempExtractDir) + } + }() + + if err := browser.ExtractCoreArchiveAndStripRootForImport(archivePath, tempExtractDir); err != nil { + return nil, fmt.Errorf("解压失败: %w", err) + } + if _, _, ok := browser.FindCoreExecutable(tempExtractDir); !ok { + return nil, fmt.Errorf("所选归档不是当前平台可用的内核包:当前平台 %s,未找到浏览器可执行文件(候选:%s)", browser.CoreExecutablePlatform(), strings.Join(browser.CoreExecutableCandidates(), ", ")) + } + if err := os.Rename(tempExtractDir, targetDir); err != nil { + return nil, err + } + cleanupTempExtract = false + + input := browser.CoreInput{ + CoreName: coreName, + CorePath: targetCorePath, + IsDefault: len(a.browserMgr.ListCores()) == 0, + } + if err := a.browserMgr.SaveCore(input); err != nil { + return nil, err + } + for _, saved := range a.browserMgr.ListCores() { + if normalizeCorePathForCompare(saved.CorePath) == normalizeCorePathForCompare(targetCorePath) { + return &saved, nil + } + } + return nil, fmt.Errorf("本地内核已保存但未能读取结果") +} + +func coreNameFromArchiveName(name string) string { + name = strings.TrimSpace(name) + for _, suffix := range []string{".tar.gz", ".tar.xz", ".tar.bz2", ".tgz", ".txz", ".tbz2", ".zip", ".tar"} { + if strings.HasSuffix(strings.ToLower(name), suffix) { + return strings.TrimSpace(name[:len(name)-len(suffix)]) + } + } + return strings.TrimSuffix(name, filepath.Ext(name)) +} + +// BrowserCoreImportLocalDirectory 选择一个已解压内核目录并直接注册,不下载、不复制文件。 +func (a *App) BrowserCoreImportLocalDirectory() (*BrowserCore, error) { + if a.ctx == nil { + return nil, fmt.Errorf("app context is nil") + } + if a.browserMgr == nil { + return nil, fmt.Errorf("browser manager is nil") + } + selectedDir, err := wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{ Title: "选择已解压的 Chrome 内核目录", }) @@ -111,7 +210,7 @@ func (a *App) BrowserCoreImportLocal() (*BrowserCore, error) { return nil, err } if _, _, ok := browser.FindCoreExecutable(absDir); !ok { - return nil, fmt.Errorf("所选目录不是有效内核目录:未找到浏览器可执行文件(候选:%s)", strings.Join(browser.CoreExecutableCandidates(), ", ")) + return nil, fmt.Errorf("所选目录不是当前平台可用的内核目录:当前平台 %s,未找到浏览器可执行文件(候选:%s)", browser.CoreExecutablePlatform(), strings.Join(browser.CoreExecutableCandidates(), ", ")) } corePath := a.relativeCorePathIfPossible(absDir) diff --git a/backend/app_utils.go b/backend/app_utils.go index 09c095a0..7307f3fd 100644 --- a/backend/app_utils.go +++ b/backend/app_utils.go @@ -186,7 +186,7 @@ func (a *App) scanChromeDir(chromeRoot string) []browser.Core { } // 如果根目录本身就有浏览器可执行文件,视为单内核结构 - if _, _, ok := browser.FindCoreExecutable(baseDir); ok { + if _, _, ok := browser.FindCoreExecutableShallow(baseDir); ok { return []browser.Core{ { CoreId: "default", diff --git a/backend/internal/browser/core_binary.go b/backend/internal/browser/core_binary.go index e3354d92..09fa9d2e 100644 --- a/backend/internal/browser/core_binary.go +++ b/backend/internal/browser/core_binary.go @@ -13,7 +13,7 @@ func CoreExecutableCandidates() []string { case "windows": return []string{"chrome.exe"} case "linux": - return []string{"chrome", "chrome-bin", "chrome.exe"} + return []string{"chrome", "chrome-bin", "chromium", "chromium-browser", "ungoogled-chromium", "chrome.exe"} case "darwin": return []string{ "Google Chrome.app/Contents/MacOS/Google Chrome", @@ -25,8 +25,22 @@ func CoreExecutableCandidates() []string { } } +func CoreExecutablePlatform() string { + return goruntime.GOOS + "/" + goruntime.GOARCH +} + // FindCoreExecutable 在指定目录查找可执行文件,返回绝对路径和命中的候选名。 func FindCoreExecutable(baseDir string) (string, string, bool) { + if directPath, directCandidate, ok := FindCoreExecutableShallow(baseDir); ok { + return directPath, directCandidate, true + } + if recursivePath, recursiveCandidate, ok := findNestedCoreExecutable(baseDir); ok { + return recursivePath, recursiveCandidate, true + } + return "", "", false +} + +func FindCoreExecutableShallow(baseDir string) (string, string, bool) { baseDir = strings.TrimSpace(baseDir) if baseDir == "" { return "", "", false @@ -46,6 +60,44 @@ func FindCoreExecutable(baseDir string) (string, string, bool) { return "", "", false } +func findNestedCoreExecutable(baseDir string) (string, string, bool) { + info, err := os.Stat(baseDir) + if err != nil || !info.IsDir() { + return "", "", false + } + baseDepth := strings.Count(filepath.ToSlash(filepath.Clean(baseDir)), "/") + candidateNames := make(map[string]string) + for _, candidate := range CoreExecutableCandidates() { + candidateNames[strings.ToLower(filepath.Base(candidate))] = candidate + } + + var matchedPath string + var matchedCandidate string + _ = filepath.WalkDir(baseDir, func(path string, entry os.DirEntry, err error) error { + if err != nil || path == baseDir || matchedPath != "" { + return nil + } + if entry.IsDir() { + depth := strings.Count(filepath.ToSlash(filepath.Clean(path)), "/") - baseDepth + if depth > 5 { + return filepath.SkipDir + } + return nil + } + candidate, ok := candidateNames[strings.ToLower(entry.Name())] + if !ok { + return nil + } + matchedPath = path + matchedCandidate = candidate + return nil + }) + if matchedPath == "" { + return "", "", false + } + return matchedPath, matchedCandidate, true +} + func findDirectCoreExecutable(path string) (string, string, bool) { info, err := os.Stat(path) if err != nil || info.IsDir() { diff --git a/backend/internal/browser/download_core_extract.go b/backend/internal/browser/download_core_extract.go index 40784047..5a43d78e 100644 --- a/backend/internal/browser/download_core_extract.go +++ b/backend/internal/browser/download_core_extract.go @@ -1,107 +1,311 @@ package browser import ( + "archive/tar" "archive/zip" + "compress/bzip2" + "compress/gzip" "fmt" "io" "os" "path/filepath" "strings" + + "github.com/ulikunitz/xz" ) -// extractZipAndStripRoot 解压 ZIP 包,如果其所有文件全被同一个根目录包裹,则剥离这层根目录解压至 dest -// progressCb 为进度回调 (0-100%, statusType_msg) -func extractZipAndStripRoot(zipPath, dest string, progressCb func(int, string)) error { - r, err := zip.OpenReader(zipPath) +type archiveEntry struct { + Name string + Mode os.FileMode + Dir bool + Open func() (io.ReadCloser, error) + LinkName string + Symlink bool +} + +func SupportedCoreArchivePattern() string { + return "*.zip;*.tar;*.tar.gz;*.tgz;*.tar.xz;*.txz;*.tar.bz2;*.tbz2" +} + +func SupportedCoreArchiveDescription() string { + return "支持 ZIP、TAR、TAR.GZ、TAR.XZ、TAR.BZ2" +} + +func coreArchiveTempPattern(rawURL string) string { + lowerName := strings.ToLower(strings.TrimSpace(rawURL)) + if parsed, err := filepathFromURLPath(lowerName); err == nil && parsed != "" { + lowerName = parsed + } + suffixes := []string{".tar.gz", ".tar.xz", ".tar.bz2", ".tgz", ".txz", ".tbz2", ".zip", ".tar"} + for _, suffix := range suffixes { + if strings.HasSuffix(lowerName, suffix) { + return "download_*" + suffix + } + } + return "download_*" +} + +func filepathFromURLPath(raw string) (string, error) { + parts := strings.SplitN(raw, "?", 2) + parts = strings.SplitN(parts[0], "#", 2) + return filepath.Base(parts[0]), nil +} + +func extractCoreArchiveAndStripRoot(archivePath, dest string, progressCb func(int, string)) error { + entries, closeEntries, err := openCoreArchiveEntries(archivePath) if err != nil { return err } - defer r.Close() + defer closeEntries() - if len(r.File) == 0 { + if len(entries) == 0 { return fmt.Errorf("空的压缩包") } - // 探测是否存在单一顶层目录 - var rootPrefix string - hasCommonRoot := true - - for _, f := range r.File { - cleanName := filepath.ToSlash(f.Name) - parts := strings.SplitN(cleanName, "/", 2) - - // 检查空名称文件,理论上不该有 - if len(parts) == 0 || parts[0] == "" { - continue - } - - if rootPrefix == "" { - rootPrefix = parts[0] + "/" - } else if !strings.HasPrefix(cleanName, rootPrefix) && cleanName != strings.TrimSuffix(rootPrefix, "/") { - hasCommonRoot = false - break - } - } - - if err := os.MkdirAll(dest, 0755); err != nil { + rootPrefix, hasCommonRoot := detectCommonArchiveRoot(entries) + if err := os.MkdirAll(dest, 0o755); err != nil { return err } - totalFiles := len(r.File) - for i, f := range r.File { - // 报告进度 (逢 5% 更新一下) - percent := int((float64(i) / float64(totalFiles)) * 100) - if i%50 == 0 { - progressCb(percent, fmt.Sprintf("正在解压文件 %d / %d...", i+1, totalFiles)) + for index, entry := range entries { + percent := int((float64(index) / float64(len(entries))) * 100) + if index%50 == 0 { + progressCb(percent, fmt.Sprintf("正在解压文件 %d / %d...", index+1, len(entries))) } - cleanName := filepath.ToSlash(f.Name) + cleanName := normalizeArchiveEntryName(entry.Name) if hasCommonRoot { if cleanName == rootPrefix || cleanName == strings.TrimSuffix(rootPrefix, "/") { - // 忽略外包装本层目录条目 continue } cleanName = strings.TrimPrefix(cleanName, rootPrefix) } - if cleanName == "" || cleanName == "/" { continue } - fpath := filepath.Join(dest, filepath.FromSlash(cleanName)) - // 防止 Zip Slip 漏洞 - if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { - return fmt.Errorf("非法文件路径: %s", fpath) - } - - if f.FileInfo().IsDir() { - os.MkdirAll(fpath, f.Mode()) - continue - } - if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { + targetPath, err := safeArchiveTargetPath(dest, cleanName) + if err != nil { return err } - - outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) - if err != nil { - return fmt.Errorf("打开解压文件写入失败 %s: %v", fpath, err) + if entry.Dir { + if err := os.MkdirAll(targetPath, entry.Mode.Perm()); err != nil { + return err + } + continue + } + if entry.Symlink { + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + _ = os.Remove(targetPath) + if err := os.Symlink(entry.LinkName, targetPath); err != nil { + return fmt.Errorf("创建符号链接失败 %s: %w", cleanName, err) + } + continue } - rc, err := f.Open() - if err != nil { - outFile.Close() - return fmt.Errorf("读取压缩包文件失败 %s: %v", f.Name, err) + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err } - - _, err = io.Copy(outFile, rc) - outFile.Close() - rc.Close() - - if err != nil { - return fmt.Errorf("写入文件流失败 %s: %v", fpath, err) + if err := writeArchiveEntryFile(targetPath, entry); err != nil { + return err } } progressCb(100, "解压完成!") return nil } + +func ExtractCoreArchiveAndStripRootForImport(archivePath, dest string) error { + return extractCoreArchiveAndStripRoot(archivePath, dest, func(int, string) {}) +} + +func openCoreArchiveEntries(archivePath string) ([]archiveEntry, func(), error) { + lower := strings.ToLower(archivePath) + if strings.HasSuffix(lower, ".zip") { + return openZipArchiveEntries(archivePath) + } + if isTarArchivePath(lower) { + return openTarArchiveEntries(archivePath) + } + if entries, closeEntries, err := openZipArchiveEntries(archivePath); err == nil { + return entries, closeEntries, nil + } + return openTarArchiveEntries(archivePath) +} + +func openZipArchiveEntries(archivePath string) ([]archiveEntry, func(), error) { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return nil, func() {}, err + } + entries := make([]archiveEntry, 0, len(reader.File)) + for _, file := range reader.File { + zipFile := file + entries = append(entries, archiveEntry{ + Name: zipFile.Name, + Mode: zipFile.Mode(), + Dir: zipFile.FileInfo().IsDir(), + Open: func() (io.ReadCloser, error) { + return zipFile.Open() + }, + }) + } + return entries, func() { _ = reader.Close() }, nil +} + +func openTarArchiveEntries(archivePath string) ([]archiveEntry, func(), error) { + file, err := os.Open(archivePath) + if err != nil { + return nil, func() {}, err + } + reader, err := tarStreamReader(archivePath, file) + if err != nil { + _ = file.Close() + return nil, func() {}, err + } + + tmpDir, err := os.MkdirTemp(filepath.Dir(archivePath), "archive_entries_*") + if err != nil { + _ = file.Close() + return nil, func() {}, err + } + cleanup := func() { + _ = file.Close() + _ = os.RemoveAll(tmpDir) + } + + tarReader := tar.NewReader(reader) + var entries []archiveEntry + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + cleanup() + return nil, func() {}, err + } + + mode := header.FileInfo().Mode() + entry := archiveEntry{ + Name: header.Name, + Mode: mode, + Dir: header.FileInfo().IsDir(), + LinkName: header.Linkname, + Symlink: header.Typeflag == tar.TypeSymlink, + } + if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA { + spoolPath := filepath.Join(tmpDir, fmt.Sprintf("entry_%06d", len(entries))) + out, err := os.OpenFile(spoolPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm()) + if err != nil { + cleanup() + return nil, func() {}, err + } + _, copyErr := io.Copy(out, tarReader) + closeErr := out.Close() + if copyErr != nil { + cleanup() + return nil, func() {}, copyErr + } + if closeErr != nil { + cleanup() + return nil, func() {}, closeErr + } + entry.Open = func() (io.ReadCloser, error) { + return os.Open(spoolPath) + } + } + entries = append(entries, entry) + } + return entries, cleanup, nil +} + +func tarStreamReader(archivePath string, file *os.File) (io.Reader, error) { + lower := strings.ToLower(archivePath) + switch { + case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"): + return gzip.NewReader(file) + case strings.HasSuffix(lower, ".tar.xz") || strings.HasSuffix(lower, ".txz"): + return xz.NewReader(file) + case strings.HasSuffix(lower, ".tar.bz2") || strings.HasSuffix(lower, ".tbz2"): + return bzip2.NewReader(file), nil + case strings.HasSuffix(lower, ".tar"): + return file, nil + default: + return file, nil + } +} + +func isTarArchivePath(path string) bool { + for _, suffix := range []string{".tar", ".tar.gz", ".tgz", ".tar.xz", ".txz", ".tar.bz2", ".tbz2"} { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} + +func detectCommonArchiveRoot(entries []archiveEntry) (string, bool) { + var rootPrefix string + for _, entry := range entries { + cleanName := normalizeArchiveEntryName(entry.Name) + parts := strings.SplitN(cleanName, "/", 2) + if len(parts) == 0 || parts[0] == "" { + continue + } + if rootPrefix == "" { + rootPrefix = parts[0] + "/" + continue + } + if !strings.HasPrefix(cleanName, rootPrefix) && cleanName != strings.TrimSuffix(rootPrefix, "/") { + return "", false + } + } + return rootPrefix, rootPrefix != "" +} + +func normalizeArchiveEntryName(name string) string { + cleanName := filepath.ToSlash(strings.TrimSpace(name)) + cleanName = strings.TrimPrefix(cleanName, "/") + return filepath.ToSlash(filepath.Clean(cleanName)) +} + +func safeArchiveTargetPath(dest, cleanName string) (string, error) { + if cleanName == "." || strings.HasPrefix(cleanName, "../") || cleanName == ".." || filepath.IsAbs(cleanName) { + return "", fmt.Errorf("非法文件路径: %s", cleanName) + } + targetPath := filepath.Join(dest, filepath.FromSlash(cleanName)) + destClean := filepath.Clean(dest) + targetClean := filepath.Clean(targetPath) + if targetClean != destClean && !strings.HasPrefix(targetClean, destClean+string(os.PathSeparator)) { + return "", fmt.Errorf("非法文件路径: %s", cleanName) + } + return targetPath, nil +} + +func writeArchiveEntryFile(targetPath string, entry archiveEntry) error { + if entry.Open == nil { + return nil + } + outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, entry.Mode.Perm()) + if err != nil { + return fmt.Errorf("打开解压文件写入失败 %s: %w", targetPath, err) + } + rc, err := entry.Open() + if err != nil { + _ = outFile.Close() + return fmt.Errorf("读取压缩包文件失败 %s: %w", entry.Name, err) + } + _, copyErr := io.Copy(outFile, rc) + closeReadErr := rc.Close() + closeWriteErr := outFile.Close() + if copyErr != nil { + return fmt.Errorf("写入文件流失败 %s: %w", targetPath, copyErr) + } + if closeReadErr != nil { + return closeReadErr + } + return closeWriteErr +} diff --git a/backend/internal/browser/download_core_task.go b/backend/internal/browser/download_core_task.go index 9e4a7005..853d1a00 100644 --- a/backend/internal/browser/download_core_task.go +++ b/backend/internal/browser/download_core_task.go @@ -128,7 +128,7 @@ func (m *Manager) downloadAndExtractCore(ctx context.Context, coreInput CoreInpu Transport: transport, } - tempFile, err := os.CreateTemp(parentDir, "download_*.zip") + tempFile, err := os.CreateTemp(parentDir, coreArchiveTempPattern(targetUrl)) if err != nil { sendEvent("error", 0, "创建临时文件失败: "+err.Error()) return @@ -164,7 +164,7 @@ func (m *Manager) downloadAndExtractCore(ctx context.Context, coreInput CoreInpu }() // 3. 执行解压,并剥离顶层文件夹 - if err := extractZipAndStripRoot(tempFilePath, tempExtractDir, func(p int, msg string) { + if err := extractCoreArchiveAndStripRoot(tempFilePath, tempExtractDir, func(p int, msg string) { sendEvent("extracting", p, msg) }); err != nil { sendEvent("error", 0, "解压失败: "+err.Error()) @@ -172,7 +172,7 @@ func (m *Manager) downloadAndExtractCore(ctx context.Context, coreInput CoreInpu } if !m.ValidateCorePath(tempExtractDir).Valid { - sendEvent("error", 0, fmt.Sprintf("解压后未找到浏览器可执行文件(候选:%s),请检查压缩包内容!", strings.Join(CoreExecutableCandidates(), ", "))) + sendEvent("error", 0, fmt.Sprintf("解压后未找到当前平台可用的浏览器可执行文件(当前平台 %s,候选:%s),请检查压缩包内容!", CoreExecutablePlatform(), strings.Join(CoreExecutableCandidates(), ", "))) return } diff --git a/frontend/src/modules/browser/pages/coreManagement/CoreDownloadModal.tsx b/frontend/src/modules/browser/pages/coreManagement/CoreDownloadModal.tsx index cb2055a8..3c52cf1f 100644 --- a/frontend/src/modules/browser/pages/coreManagement/CoreDownloadModal.tsx +++ b/frontend/src/modules/browser/pages/coreManagement/CoreDownloadModal.tsx @@ -69,11 +69,11 @@ export function CoreDownloadModal({ )} - + setForm(prev => ({ ...prev, url: e.target.value }))} - placeholder="https://github.com/.../release.zip" + placeholder="https://github.com/.../chrome-linux.tar.xz" disabled={progress !== null} />