feat: support browser core archive imports

This commit is contained in:
ant-black
2026-06-28 23:07:06 +08:00
parent 0cd4a9df3b
commit 3c34485c26
6 changed files with 427 additions and 72 deletions
+101 -2
View File
@@ -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)
+1 -1
View File
@@ -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",
+53 -1
View File
@@ -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() {
+267 -63
View File
@@ -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
}
@@ -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
}
@@ -69,11 +69,11 @@ export function CoreDownloadModal({
</div>
)}
<FormItem label="下载地址 (ZIP)" required>
<FormItem label="下载地址" required>
<Input
value={form.url}
onChange={e => setForm(prev => ({ ...prev, url: e.target.value }))}
placeholder="https://github.com/.../release.zip"
placeholder="https://github.com/.../chrome-linux.tar.xz"
disabled={progress !== null}
/>
<div className="text-xs text-[var(--color-text-muted)] mt-2 flex items-center justify-between bg-[var(--color-bg-muted)] p-2 rounded">