mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
publish: 1.0.0 snapshot (bad2ec1)
channel: master version: 1.0.0 source-ref: master published-at-utc: 2026-03-13T15:19:28Z
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
)
|
||||
|
||||
// BookmarkDAO 默认书签持久化接口
|
||||
type BookmarkDAO interface {
|
||||
List() ([]config.BrowserBookmark, error)
|
||||
ReplaceAll(bookmarks []config.BrowserBookmark) error
|
||||
}
|
||||
|
||||
// SQLiteBookmarkDAO 基于 SQLite 的 BookmarkDAO 实现
|
||||
type SQLiteBookmarkDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteBookmarkDAO 创建 SQLiteBookmarkDAO
|
||||
func NewSQLiteBookmarkDAO(db *sql.DB) *SQLiteBookmarkDAO {
|
||||
return &SQLiteBookmarkDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有默认书签,按 sort_order 升序
|
||||
func (d *SQLiteBookmarkDAO) List() ([]config.BrowserBookmark, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT name, url FROM browser_bookmarks ORDER BY sort_order ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询书签列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []config.BrowserBookmark
|
||||
for rows.Next() {
|
||||
var b config.BrowserBookmark
|
||||
if err := rows.Scan(&b.Name, &b.URL); err != nil {
|
||||
return nil, fmt.Errorf("读取书签行失败: %w", err)
|
||||
}
|
||||
list = append(list, b)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceAll 原子替换全部书签(事务保证)
|
||||
func (d *SQLiteBookmarkDAO) ReplaceAll(bookmarks []config.BrowserBookmark) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM browser_bookmarks`); err != nil {
|
||||
return fmt.Errorf("清空书签失败: %w", err)
|
||||
}
|
||||
for i, b := range bookmarks {
|
||||
if b.Name == "" || b.URL == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO browser_bookmarks (name, url, sort_order) VALUES (?, ?, ?)`,
|
||||
b.Name, b.URL, i,
|
||||
); err != nil {
|
||||
return fmt.Errorf("插入书签失败: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chromiumEpoch 是 Chrome FILETIME 的起始时间(1601-01-01 UTC)
|
||||
var chromiumEpoch = time.Date(1601, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func toChromiumTime(t time.Time) string {
|
||||
return fmt.Sprintf("%d", t.Sub(chromiumEpoch).Microseconds())
|
||||
}
|
||||
|
||||
// EnsureDefaultBookmarks 将默认书签合并到书签栏(已存在的 URL 不重复添加)
|
||||
func EnsureDefaultBookmarks(userDataDir string, bookmarks []config.BrowserBookmark) error {
|
||||
if len(bookmarks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
profileDir := filepath.Join(userDataDir, "Default")
|
||||
if err := os.MkdirAll(profileDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建 profile 目录失败: %w", err)
|
||||
}
|
||||
|
||||
bookmarksPath := filepath.Join(profileDir, "Bookmarks")
|
||||
|
||||
// 尝试读取已有书签文件
|
||||
var root map[string]interface{}
|
||||
if data, err := os.ReadFile(bookmarksPath); err == nil {
|
||||
_ = json.Unmarshal(data, &root)
|
||||
}
|
||||
|
||||
now := toChromiumTime(time.Now())
|
||||
|
||||
// 初始化空结构
|
||||
if root == nil {
|
||||
root = newEmptyBookmarkRoot(now)
|
||||
}
|
||||
|
||||
// 取出 bookmark_bar children,收集已有 URL 集合
|
||||
barChildren, existingURLs := extractBarChildren(root)
|
||||
|
||||
// 计算当前最大 id,用于分配新 id
|
||||
maxID := findMaxID(root)
|
||||
|
||||
// 把不存在的默认书签追加进去
|
||||
for _, b := range bookmarks {
|
||||
if existingURLs[b.URL] {
|
||||
continue
|
||||
}
|
||||
maxID++
|
||||
barChildren = append(barChildren, map[string]interface{}{
|
||||
"date_added": now,
|
||||
"date_last_used": "0",
|
||||
"guid": bookmarkGUID(b.URL),
|
||||
"id": fmt.Sprintf("%d", maxID),
|
||||
"meta_info": map[string]string{"power_bookmark_meta": ""},
|
||||
"name": b.Name,
|
||||
"type": "url",
|
||||
"url": b.URL,
|
||||
})
|
||||
}
|
||||
|
||||
// 写回
|
||||
roots := root["roots"].(map[string]interface{})
|
||||
bar := roots["bookmark_bar"].(map[string]interface{})
|
||||
bar["children"] = barChildren
|
||||
bar["date_modified"] = now
|
||||
roots["bookmark_bar"] = bar
|
||||
root["roots"] = roots
|
||||
|
||||
out, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化书签失败: %w", err)
|
||||
}
|
||||
return os.WriteFile(bookmarksPath, out, 0644)
|
||||
}
|
||||
|
||||
// newEmptyBookmarkRoot 构建一个空的书签根结构
|
||||
func newEmptyBookmarkRoot(now string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"checksum": "",
|
||||
"version": 1,
|
||||
"roots": map[string]interface{}{
|
||||
"bookmark_bar": map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"date_added": now,
|
||||
"date_last_used": "0",
|
||||
"date_modified": now,
|
||||
"guid": "0bc5d13f-2cba-5d74-951f-3f233fe6c908",
|
||||
"id": "1",
|
||||
"name": "书签栏",
|
||||
"type": "folder",
|
||||
},
|
||||
"other": map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"date_added": now,
|
||||
"date_last_used": "0",
|
||||
"date_modified": "0",
|
||||
"guid": "82b081ec-3dd3-529c-8475-ab6c344590dd",
|
||||
"id": "2",
|
||||
"name": "其他书签",
|
||||
"type": "folder",
|
||||
},
|
||||
"synced": map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"date_added": now,
|
||||
"date_last_used": "0",
|
||||
"date_modified": "0",
|
||||
"guid": "4cf2e351-0e85-532b-bb37-df045d8f8d0f",
|
||||
"id": "3",
|
||||
"name": "移动设备书签",
|
||||
"type": "folder",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// extractBarChildren 从根结构中提取书签栏 children 和已有 URL 集合
|
||||
func extractBarChildren(root map[string]interface{}) ([]interface{}, map[string]bool) {
|
||||
existing := map[string]bool{}
|
||||
var children []interface{}
|
||||
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
if !ok {
|
||||
root["roots"] = map[string]interface{}{
|
||||
"bookmark_bar": map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"type": "folder",
|
||||
"name": "书签栏",
|
||||
},
|
||||
}
|
||||
return children, existing
|
||||
}
|
||||
|
||||
bar, ok := roots["bookmark_bar"].(map[string]interface{})
|
||||
if !ok {
|
||||
roots["bookmark_bar"] = map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"type": "folder",
|
||||
"name": "书签栏",
|
||||
}
|
||||
root["roots"] = roots
|
||||
return children, existing
|
||||
}
|
||||
|
||||
if c, ok := bar["children"].([]interface{}); ok {
|
||||
children = c
|
||||
collectURLs(c, existing)
|
||||
}
|
||||
return children, existing
|
||||
}
|
||||
|
||||
// collectURLs 递归收集所有书签 URL
|
||||
func collectURLs(nodes []interface{}, out map[string]bool) {
|
||||
for _, n := range nodes {
|
||||
node, ok := n.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if node["type"] == "url" {
|
||||
if u, ok := node["url"].(string); ok {
|
||||
out[u] = true
|
||||
}
|
||||
} else if node["type"] == "folder" {
|
||||
if sub, ok := node["children"].([]interface{}); ok {
|
||||
collectURLs(sub, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findMaxID 遍历整个书签树找到最大数字 id
|
||||
func findMaxID(root map[string]interface{}) int {
|
||||
max := 0
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
if !ok {
|
||||
return max
|
||||
}
|
||||
for _, v := range roots {
|
||||
if folder, ok := v.(map[string]interface{}); ok {
|
||||
scanMaxID(folder, &max)
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func scanMaxID(node map[string]interface{}, max *int) {
|
||||
if idStr, ok := node["id"].(string); ok {
|
||||
var n int
|
||||
fmt.Sscanf(idStr, "%d", &n)
|
||||
if n > *max {
|
||||
*max = n
|
||||
}
|
||||
}
|
||||
if children, ok := node["children"].([]interface{}); ok {
|
||||
for _, c := range children {
|
||||
if child, ok := c.(map[string]interface{}); ok {
|
||||
scanMaxID(child, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bookmarkGUID 根据 URL 生成稳定伪 GUID
|
||||
func bookmarkGUID(url string) string {
|
||||
h := uint64(14695981039346656037)
|
||||
for _, c := range url {
|
||||
h ^= uint64(c)
|
||||
h *= 1099511628211
|
||||
}
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
h&0xffffffff, (h>>32)&0xffff,
|
||||
(h>>48)&0x0fff|0x4000,
|
||||
(h>>16)&0x3fff|0x8000,
|
||||
h&0xffffffffffff,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package browser
|
||||
|
||||
// BuildLaunchArgs 构建启动参数
|
||||
func BuildLaunchArgs(args []string, profile *Profile) []string {
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GetCore 根据 coreId 获取内核配置
|
||||
func (m *Manager) GetCore(coreId string) (Core, bool) {
|
||||
coreId = strings.TrimSpace(coreId)
|
||||
if coreId == "" {
|
||||
return Core{}, false
|
||||
}
|
||||
for _, core := range m.Config.Browser.Cores {
|
||||
if strings.EqualFold(core.CoreId, coreId) {
|
||||
return core, true
|
||||
}
|
||||
}
|
||||
return Core{}, false
|
||||
}
|
||||
|
||||
// GetDefaultCore 获取默认内核
|
||||
func (m *Manager) GetDefaultCore() (Core, bool) {
|
||||
for _, core := range m.Config.Browser.Cores {
|
||||
if core.IsDefault {
|
||||
return core, true
|
||||
}
|
||||
}
|
||||
if len(m.Config.Browser.Cores) > 0 {
|
||||
return m.Config.Browser.Cores[0], true
|
||||
}
|
||||
return Core{}, false
|
||||
}
|
||||
|
||||
// ResolveCoreExecutable 解析内核可执行文件路径
|
||||
func (m *Manager) ResolveCoreExecutable(core Core) (string, error) {
|
||||
corePath := strings.TrimSpace(core.CorePath)
|
||||
if corePath == "" {
|
||||
return "", fmt.Errorf("浏览器内核路径为空,请在“内核管理”中补充内核目录")
|
||||
}
|
||||
|
||||
baseDir := m.ResolveRelativePath(corePath)
|
||||
|
||||
exePath := filepath.Join(baseDir, "chrome.exe")
|
||||
if _, err := os.Stat(exePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("浏览器内核目录无效:未找到 chrome.exe(%s)。请检查内核目录是否完整或重新下载内核", exePath)
|
||||
}
|
||||
return "", fmt.Errorf("浏览器内核目录不可访问:%s。原因:%v", exePath, err)
|
||||
}
|
||||
|
||||
return exePath, nil
|
||||
}
|
||||
|
||||
// ValidateCorePath 验证内核路径是否有效
|
||||
func (m *Manager) ValidateCorePath(corePath string) CoreValidateResult {
|
||||
corePath = strings.TrimSpace(corePath)
|
||||
if corePath == "" {
|
||||
return CoreValidateResult{Valid: false, Message: "路径不能为空"}
|
||||
}
|
||||
|
||||
baseDir := m.ResolveRelativePath(corePath)
|
||||
|
||||
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
|
||||
return CoreValidateResult{Valid: false, Message: fmt.Sprintf("目录不存在: %s", baseDir)}
|
||||
}
|
||||
|
||||
exePath := filepath.Join(baseDir, "chrome.exe")
|
||||
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||
return CoreValidateResult{Valid: false, Message: fmt.Sprintf("chrome.exe 不存在: %s", exePath)}
|
||||
}
|
||||
|
||||
return CoreValidateResult{Valid: true, Message: fmt.Sprintf("路径有效: %s", exePath)}
|
||||
}
|
||||
|
||||
// ListCores 获取所有内核配置
|
||||
func (m *Manager) ListCores() []Core {
|
||||
if m.CoreDAO != nil {
|
||||
cores, err := m.CoreDAO.List()
|
||||
if err == nil {
|
||||
// 同步到内存 config,供其他逻辑使用
|
||||
m.Config.Browser.Cores = cores
|
||||
return cores
|
||||
}
|
||||
}
|
||||
return m.Config.Browser.Cores
|
||||
}
|
||||
|
||||
// SaveCore 保存内核配置(新增或更新)
|
||||
func (m *Manager) SaveCore(input CoreInput) error {
|
||||
log := logger.New("Browser")
|
||||
coreId := strings.TrimSpace(input.CoreId)
|
||||
coreName := strings.TrimSpace(input.CoreName)
|
||||
corePath := strings.TrimSpace(input.CorePath)
|
||||
|
||||
if coreName == "" {
|
||||
return fmt.Errorf("内核名称不能为空")
|
||||
}
|
||||
if corePath == "" {
|
||||
return fmt.Errorf("内核路径不能为空")
|
||||
}
|
||||
|
||||
if m.CoreDAO != nil {
|
||||
if coreId == "" {
|
||||
coreId = uuid.NewString()
|
||||
}
|
||||
if input.IsDefault {
|
||||
if err := m.CoreDAO.SetDefault(""); err != nil {
|
||||
// SetDefault 空串只清除,忽略错误
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
core := Core{CoreId: coreId, CoreName: coreName, CorePath: corePath, IsDefault: input.IsDefault}
|
||||
if err := m.CoreDAO.Upsert(core); err != nil {
|
||||
return err
|
||||
}
|
||||
// 同步内存
|
||||
m.syncCoresFromDAO()
|
||||
log.Info("内核配置保存", logger.F("core_id", coreId), logger.F("core_name", coreName))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级:写 config.yaml
|
||||
existingIndex := -1
|
||||
for i, core := range m.Config.Browser.Cores {
|
||||
if coreId != "" && strings.EqualFold(core.CoreId, coreId) {
|
||||
existingIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if existingIndex >= 0 {
|
||||
m.Config.Browser.Cores[existingIndex].CoreName = coreName
|
||||
m.Config.Browser.Cores[existingIndex].CorePath = corePath
|
||||
if input.IsDefault {
|
||||
m.clearDefaultCore()
|
||||
m.Config.Browser.Cores[existingIndex].IsDefault = true
|
||||
}
|
||||
} else {
|
||||
if coreId == "" {
|
||||
coreId = uuid.NewString()
|
||||
}
|
||||
newCore := Core{CoreId: coreId, CoreName: coreName, CorePath: corePath,
|
||||
IsDefault: input.IsDefault || len(m.Config.Browser.Cores) == 0}
|
||||
if newCore.IsDefault {
|
||||
m.clearDefaultCore()
|
||||
}
|
||||
m.Config.Browser.Cores = append(m.Config.Browser.Cores, newCore)
|
||||
}
|
||||
log.Info("内核配置保存(文件)", logger.F("core_id", coreId))
|
||||
return m.Config.Save(m.ResolveRelativePath("config.yaml"))
|
||||
}
|
||||
|
||||
// DeleteCore 删除内核配置
|
||||
func (m *Manager) DeleteCore(coreId string) error {
|
||||
log := logger.New("Browser")
|
||||
coreId = strings.TrimSpace(coreId)
|
||||
if coreId == "" {
|
||||
return fmt.Errorf("内核ID不能为空")
|
||||
}
|
||||
|
||||
if m.CoreDAO != nil {
|
||||
if err := m.CoreDAO.Delete(coreId); err != nil {
|
||||
return err
|
||||
}
|
||||
m.syncCoresFromDAO()
|
||||
log.Info("内核配置删除", logger.F("core_id", coreId))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级
|
||||
index := -1
|
||||
for i, core := range m.Config.Browser.Cores {
|
||||
if strings.EqualFold(core.CoreId, coreId) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return fmt.Errorf("内核不存在: %s", coreId)
|
||||
}
|
||||
wasDefault := m.Config.Browser.Cores[index].IsDefault
|
||||
m.Config.Browser.Cores = append(m.Config.Browser.Cores[:index], m.Config.Browser.Cores[index+1:]...)
|
||||
if wasDefault && len(m.Config.Browser.Cores) > 0 {
|
||||
m.Config.Browser.Cores[0].IsDefault = true
|
||||
}
|
||||
log.Info("内核配置删除(文件)", logger.F("core_id", coreId))
|
||||
return m.Config.Save(m.ResolveRelativePath("config.yaml"))
|
||||
}
|
||||
|
||||
// SetDefaultCore 设置默认内核
|
||||
func (m *Manager) SetDefaultCore(coreId string) error {
|
||||
log := logger.New("Browser")
|
||||
coreId = strings.TrimSpace(coreId)
|
||||
if coreId == "" {
|
||||
return fmt.Errorf("内核ID不能为空")
|
||||
}
|
||||
|
||||
if m.CoreDAO != nil {
|
||||
if err := m.CoreDAO.SetDefault(coreId); err != nil {
|
||||
return err
|
||||
}
|
||||
m.syncCoresFromDAO()
|
||||
log.Info("设置默认内核", logger.F("core_id", coreId))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级
|
||||
found := false
|
||||
for i := range m.Config.Browser.Cores {
|
||||
if strings.EqualFold(m.Config.Browser.Cores[i].CoreId, coreId) {
|
||||
m.Config.Browser.Cores[i].IsDefault = true
|
||||
found = true
|
||||
} else {
|
||||
m.Config.Browser.Cores[i].IsDefault = false
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("内核不存在: %s", coreId)
|
||||
}
|
||||
log.Info("设置默认内核(文件)", logger.F("core_id", coreId))
|
||||
return m.Config.Save(m.ResolveRelativePath("config.yaml"))
|
||||
}
|
||||
|
||||
// syncCoresFromDAO 从 DAO 同步内核列表到内存 config
|
||||
func (m *Manager) syncCoresFromDAO() {
|
||||
if m.CoreDAO == nil {
|
||||
return
|
||||
}
|
||||
if cores, err := m.CoreDAO.List(); err == nil {
|
||||
m.Config.Browser.Cores = cores
|
||||
}
|
||||
}
|
||||
|
||||
// clearDefaultCore 清除所有默认标记
|
||||
func (m *Manager) clearDefaultCore() {
|
||||
for i := range m.Config.Browser.Cores {
|
||||
m.Config.Browser.Cores[i].IsDefault = false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveChromeBinary 解析 Chrome 二进制路径(简化版)
|
||||
func (m *Manager) ResolveChromeBinary(profile *Profile) (string, error) {
|
||||
log := logger.New("Browser")
|
||||
coreId := strings.TrimSpace(profile.CoreId)
|
||||
|
||||
var core Core
|
||||
var found bool
|
||||
|
||||
if coreId != "" {
|
||||
core, found = m.GetCore(coreId)
|
||||
}
|
||||
if !found {
|
||||
core, found = m.GetDefaultCore()
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("未配置可用浏览器内核。请先在“内核管理”中添加内核并设置默认内核")
|
||||
}
|
||||
|
||||
exePath, err := m.ResolveCoreExecutable(core)
|
||||
if err != nil {
|
||||
log.Error("内核路径解析失败", logger.F("core_id", core.CoreId), logger.F("error", err.Error()))
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Debug("使用内核", logger.F("core_id", core.CoreId), logger.F("path", exePath))
|
||||
return exePath, nil
|
||||
}
|
||||
|
||||
// GetChromeVersion 从 manifest.json 读取 Chrome 版本号
|
||||
func (m *Manager) GetChromeVersion(corePath string) string {
|
||||
corePath = strings.TrimSpace(corePath)
|
||||
if corePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
baseDir := m.ResolveRelativePath(corePath)
|
||||
|
||||
// 尝试读取 manifest.json 或 *.manifest 文件
|
||||
manifestPath := filepath.Join(baseDir, "manifest.json")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
// 尝试查找 *.manifest 文件
|
||||
matches, _ := filepath.Glob(filepath.Join(baseDir, "*.manifest"))
|
||||
if len(matches) > 0 {
|
||||
// 从文件名提取版本号,如 "142.0.7444.175.manifest"
|
||||
baseName := filepath.Base(matches[0])
|
||||
version := strings.TrimSuffix(baseName, ".manifest")
|
||||
if version != "" {
|
||||
return version
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
var manifest struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return manifest.Version
|
||||
}
|
||||
|
||||
// CountInstancesByCore 统计使用指定内核的实例数量
|
||||
func (m *Manager) CountInstancesByCore(coreId string) int {
|
||||
coreId = strings.TrimSpace(coreId)
|
||||
count := 0
|
||||
for _, profile := range m.Config.Browser.Profiles {
|
||||
profileCoreId := strings.TrimSpace(profile.CoreId)
|
||||
// 如果实例的 CoreId 为空,则使用默认内核
|
||||
if profileCoreId == "" {
|
||||
defaultCore, found := m.GetDefaultCore()
|
||||
if found && strings.EqualFold(defaultCore.CoreId, coreId) {
|
||||
count++
|
||||
}
|
||||
} else if strings.EqualFold(profileCoreId, coreId) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetCoresExtendedInfo 获取所有内核的扩展信息
|
||||
func (m *Manager) GetCoresExtendedInfo() []CoreExtendedInfo {
|
||||
cores := m.ListCores()
|
||||
result := make([]CoreExtendedInfo, 0, len(cores))
|
||||
for _, core := range cores {
|
||||
info := CoreExtendedInfo{
|
||||
CoreId: core.CoreId,
|
||||
ChromeVersion: m.GetChromeVersion(core.CorePath),
|
||||
InstanceCount: m.CountInstancesByCore(core.CoreId),
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CoreDAO 内核配置持久化接口
|
||||
type CoreDAO interface {
|
||||
List() ([]Core, error)
|
||||
Upsert(core Core) error
|
||||
Delete(coreId string) error
|
||||
SetDefault(coreId string) error
|
||||
}
|
||||
|
||||
// SQLiteCoreDAO 基于 SQLite 的 CoreDAO 实现
|
||||
type SQLiteCoreDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteCoreDAO 创建 SQLiteCoreDAO
|
||||
func NewSQLiteCoreDAO(db *sql.DB) *SQLiteCoreDAO {
|
||||
return &SQLiteCoreDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有内核,按 sort_order 升序
|
||||
func (d *SQLiteCoreDAO) List() ([]Core, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT core_id, core_name, core_path, is_default
|
||||
FROM browser_cores ORDER BY sort_order ASC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询内核列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []Core
|
||||
for rows.Next() {
|
||||
var c Core
|
||||
var isDefault int
|
||||
if err := rows.Scan(&c.CoreId, &c.CoreName, &c.CorePath, &isDefault); err != nil {
|
||||
return nil, fmt.Errorf("读取内核行失败: %w", err)
|
||||
}
|
||||
c.IsDefault = isDefault == 1
|
||||
list = append(list, c)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert 新增或更新内核配置
|
||||
func (d *SQLiteCoreDAO) Upsert(core Core) error {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
isDefault := 0
|
||||
if core.IsDefault {
|
||||
isDefault = 1
|
||||
}
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO browser_cores (core_id, core_name, core_path, is_default, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(core_id) DO UPDATE SET
|
||||
core_name = excluded.core_name,
|
||||
core_path = excluded.core_path,
|
||||
is_default = excluded.is_default`,
|
||||
core.CoreId, core.CoreName, core.CorePath, isDefault, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存内核配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除内核配置
|
||||
func (d *SQLiteCoreDAO) Delete(coreId string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM browser_cores WHERE core_id = ?`, coreId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除内核配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDefault 设置默认内核(先清除所有默认标记,再设置指定内核)
|
||||
func (d *SQLiteCoreDAO) SetDefault(coreId string) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`UPDATE browser_cores SET is_default = 0`); err != nil {
|
||||
return fmt.Errorf("清除默认内核失败: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE browser_cores SET is_default = 1 WHERE core_id = ?`, coreId); err != nil {
|
||||
return fmt.Errorf("设置默认内核失败: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"github.com/google/uuid"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// DownloadProgress 进度信息载体
|
||||
type DownloadProgress struct {
|
||||
Phase string `json:"phase"` // "downloading" 或 "extracting" 或 "done" 或 "error"
|
||||
Progress int `json:"progress"` // 进度百分比 0-100
|
||||
Message string `json:"message"` // 附加详情
|
||||
}
|
||||
|
||||
type coreDownloadWriter struct {
|
||||
writeFunc func(p []byte) (n int, err error)
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (cw *coreDownloadWriter) Write(p []byte) (int, error) {
|
||||
select {
|
||||
case <-cw.ctx.Done():
|
||||
return 0, cw.ctx.Err()
|
||||
default:
|
||||
}
|
||||
return cw.writeFunc(p)
|
||||
}
|
||||
|
||||
// readWindowsSystemProxy 从 Windows 注册表读取当前系统代理(WinINet,Clash 就是写这里)
|
||||
// 返回格式如 "http://127.0.0.1:7890" 或 "socks5://127.0.0.1:7891"
|
||||
func readWindowsSystemProxy() (string, error) {
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER,
|
||||
`Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
|
||||
registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
enabled, _, err := k.GetIntegerValue("ProxyEnable")
|
||||
if err != nil || enabled == 0 {
|
||||
return "", fmt.Errorf("系统代理未启用")
|
||||
}
|
||||
|
||||
proxyServer, _, err := k.GetStringValue("ProxyServer")
|
||||
if err != nil || proxyServer == "" {
|
||||
return "", fmt.Errorf("代理地址为空")
|
||||
}
|
||||
|
||||
// proxyServer 格式可能是 "127.0.0.1:7890" 或 "http=..;https=.." 多协议格式
|
||||
// 如果不含协议前缀,默认给 http://
|
||||
if !strings.Contains(proxyServer, ":") {
|
||||
return "", fmt.Errorf("无效的代理格式: %s", proxyServer)
|
||||
}
|
||||
if !strings.HasPrefix(proxyServer, "http") && !strings.HasPrefix(proxyServer, "socks") {
|
||||
return "http://" + proxyServer, nil
|
||||
}
|
||||
return proxyServer, nil
|
||||
}
|
||||
|
||||
// DownloadAndExtractCore 执行异步下载解压并在过程中发送事件
|
||||
func (m *Manager) DownloadAndExtractCore(ctx context.Context, coreName string, targetUrl string, proxyConfig string) {
|
||||
log := logger.New("Browser")
|
||||
t := time.Now()
|
||||
|
||||
sendEvent := func(phase string, progress int, msg string) {
|
||||
runtime.EventsEmit(ctx, "download:progress", DownloadProgress{
|
||||
Phase: phase,
|
||||
Progress: progress,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
sendEvent("downloading", 0, "开始解析地址并创建下载请求: "+targetUrl)
|
||||
|
||||
// 1. 检查名称重复
|
||||
coreName = strings.TrimSpace(coreName)
|
||||
for _, c := range m.ListCores() {
|
||||
if strings.EqualFold(c.CoreName, coreName) || filepath.Base(c.CorePath) == coreName {
|
||||
sendEvent("error", 0, "名称已存在,请换一个名称")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 确保外层 chrome/ 目录存在
|
||||
chromeDir := m.ResolveRelativePath("chrome")
|
||||
if err := os.MkdirAll(chromeDir, 0755); err != nil {
|
||||
sendEvent("error", 0, "创建 chrome 目录失败")
|
||||
return
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(chromeDir, coreName)
|
||||
if _, err := os.Stat(targetDir); !os.IsNotExist(err) {
|
||||
sendEvent("error", 0, "同名文件夹已存在: "+coreName)
|
||||
return
|
||||
}
|
||||
// 2. 准备 HttpClient(优先从 Windows 注册表读取真实系统代理,而非仅靠环境变量)
|
||||
transport := &http.Transport{}
|
||||
if proxyConfig == "__system__" {
|
||||
// http.ProxyFromEnvironment 只读环境变量,而 Clash 的全局代理写在 Windows 注册表里
|
||||
// 必须直接读取注册表才能拿到正确的代理地址
|
||||
if sysProxy, rErr := readWindowsSystemProxy(); rErr == nil && sysProxy != "" {
|
||||
if proxyURL, pErr := url.Parse(sysProxy); pErr == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
sendEvent("downloading", 0, "已从系统注册表读取代理: "+sysProxy)
|
||||
} else {
|
||||
// 解析失败则回退到环境变量
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
} else {
|
||||
// 没有系统代理配置或读取失败,尝试环境变量兜底
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
sendEvent("downloading", 0, "系统注册表无代理配置,使用环境变量兜底")
|
||||
}
|
||||
} else if proxyConfig != "" && proxyConfig != "direct://" && proxyConfig != "__direct__" {
|
||||
if proxyURL, pErr := url.Parse(proxyConfig); pErr == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
} else {
|
||||
sendEvent("error", 0, "代理地址解析失败: "+pErr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 0, // 取消全局超时,依靠 context 和分片连接维持
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(chromeDir, "download_*.zip")
|
||||
if err != nil {
|
||||
sendEvent("error", 0, "创建临时文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
tempFilePath := tempFile.Name()
|
||||
defer func() {
|
||||
tempFile.Close()
|
||||
os.Remove(tempFilePath) // 清理临时文件
|
||||
}()
|
||||
|
||||
sendEvent("downloading", 0, "开始分析下载链接(检测多线程支持)...")
|
||||
|
||||
err = doConcurrentDownload(ctx, client, targetUrl, tempFile, sendEvent)
|
||||
if err != nil {
|
||||
sendEvent("error", 0, "下载失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tempFile.Close() // 解压前先关闭写句柄
|
||||
sendEvent("extracting", 0, "下载完成,正在准备解压文件...")
|
||||
log.Info("内核下载完成", logger.F("url", targetUrl), logger.F("temp", tempFilePath), logger.F("cost", time.Since(t).String()))
|
||||
|
||||
// 3. 执行解压,并剥离顶层文件夹
|
||||
if err := extractZipAndStripRoot(tempFilePath, targetDir, func(p int, msg string) {
|
||||
sendEvent("extracting", p, msg)
|
||||
}); err != nil {
|
||||
os.RemoveAll(targetDir) // 删除不完整的解压文件
|
||||
sendEvent("error", 0, "解压失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 将新内核配置入库
|
||||
corePath := filepath.Join("chrome", coreName)
|
||||
if m.ValidateCorePath(corePath).Valid {
|
||||
newCore := CoreInput{
|
||||
CoreId: uuid.NewString(), // 使用固定的 UUID 或生成新的
|
||||
CoreName: coreName,
|
||||
CorePath: corePath,
|
||||
IsDefault: len(m.ListCores()) == 0, // 如果没有其他内核,这设为默认
|
||||
}
|
||||
if err := m.SaveCore(newCore); err != nil {
|
||||
sendEvent("error", 0, "保存配置入库失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
sendEvent("done", 100, "内核下载与配置成功!")
|
||||
log.Info("内核下载配置入库成功", logger.F("core_name", coreName))
|
||||
} else {
|
||||
os.RemoveAll(targetDir) // 删除不正确的解压内容
|
||||
sendEvent("error", 0, "解压后在目录未找到 chrome.exe 执行文件,请检查压缩包内容!")
|
||||
}
|
||||
}
|
||||
|
||||
// extractZipAndStripRoot 解压 ZIP 包,如果其所有文件全被同一个根目录包裹,则剥离这层根目录解压至 dest
|
||||
// progressCb 为进度回调 (0-100%, statusType_msg)
|
||||
func extractZipAndStripRoot(zipPath, dest string, progressCb func(int, string)) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if len(r.File) == 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 {
|
||||
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))
|
||||
}
|
||||
|
||||
cleanName := filepath.ToSlash(f.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 {
|
||||
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)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
outFile.Close()
|
||||
return fmt.Errorf("读取压缩包文件失败 %s: %v", f.Name, err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
outFile.Close()
|
||||
rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入文件流失败 %s: %v", fpath, err)
|
||||
}
|
||||
}
|
||||
|
||||
progressCb(100, "解压完成!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func doConcurrentDownload(ctx context.Context, client *http.Client, targetUrl string, tempFile *os.File, sendEvent func(string, int, string)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetUrl, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("HTTP状态码异常: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var totalSize int64 = resp.ContentLength
|
||||
supportRange := resp.StatusCode == http.StatusPartialContent
|
||||
|
||||
if supportRange {
|
||||
cr := resp.Header.Get("Content-Range")
|
||||
if cr != "" {
|
||||
parts := strings.Split(cr, "/")
|
||||
if len(parts) == 2 {
|
||||
fmt.Sscanf(parts[1], "%d", &totalSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if totalSize <= 0 || !supportRange {
|
||||
sendEvent("downloading", 0, "服务器不支持多线程,回退至单流下载...")
|
||||
return doSingleThreadDownload(ctx, client, targetUrl, tempFile, totalSize, sendEvent)
|
||||
}
|
||||
|
||||
sendEvent("downloading", 0, fmt.Sprintf("支持多线程分片下载,总大小 %.2f MB", float64(totalSize)/1024/1024))
|
||||
|
||||
if err := tempFile.Truncate(totalSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numWorkers := 8
|
||||
chunkSize := totalSize / int64(numWorkers)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var downloaded int64
|
||||
var mu sync.Mutex
|
||||
var lastTick time.Time
|
||||
var downloadErr error
|
||||
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
start := int64(i) * chunkSize
|
||||
end := start + chunkSize - 1
|
||||
if i == numWorkers-1 {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(part int, start, end int64) {
|
||||
defer wg.Done()
|
||||
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetUrl, nil)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if downloadErr == nil {
|
||||
downloadErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
|
||||
pResp, err := client.Do(req)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
buf := make([]byte, 256*1024)
|
||||
var written int64
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
pResp.Body.Close()
|
||||
return
|
||||
}
|
||||
n, rErr := pResp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
tempFile.WriteAt(buf[:n], start+written)
|
||||
written += int64(n)
|
||||
|
||||
mu.Lock()
|
||||
downloaded += int64(n)
|
||||
if time.Since(lastTick) > time.Second {
|
||||
percent := int((float64(downloaded) / float64(totalSize)) * 100)
|
||||
sendEvent("downloading", percent, fmt.Sprintf("并行下载中... %.2f MB / %.2f MB", float64(downloaded)/1024/1024, float64(totalSize)/1024/1024))
|
||||
lastTick = time.Now()
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
if rErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rErr != nil {
|
||||
mu.Lock()
|
||||
if downloadErr == nil {
|
||||
downloadErr = rErr
|
||||
}
|
||||
mu.Unlock()
|
||||
pResp.Body.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
pResp.Body.Close()
|
||||
return
|
||||
}
|
||||
}(i, start, end)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return downloadErr
|
||||
}
|
||||
|
||||
func doSingleThreadDownload(ctx context.Context, client *http.Client, targetUrl string, tempFile *os.File, totalSize int64, sendEvent func(string, int, string)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetUrl, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP状态码异常: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var downloaded int64
|
||||
var lastTick time.Time
|
||||
|
||||
pw := &coreDownloadWriter{
|
||||
writeFunc: func(p []byte) (n int, err error) {
|
||||
n, err = tempFile.Write(p)
|
||||
if n > 0 {
|
||||
downloaded += int64(n)
|
||||
if totalSize > 0 && time.Since(lastTick) > time.Second {
|
||||
percent := int((float64(downloaded) / float64(totalSize)) * 100)
|
||||
sendEvent("downloading", percent, fmt.Sprintf("单流下载中... %.2f MB / %.2f MB", float64(downloaded)/1024/1024, float64(totalSize)/1024/1024))
|
||||
lastTick = time.Now()
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
},
|
||||
ctx: ctx,
|
||||
}
|
||||
|
||||
buf := make([]byte, 1024*1024)
|
||||
_, err = io.CopyBuffer(pw, resp.Body, buf)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetProxyConfigById 根据代理 ID 获取代理配置
|
||||
func (m *Manager) GetProxyConfigById(proxyId string) (string, bool) {
|
||||
proxyId = strings.TrimSpace(proxyId)
|
||||
if proxyId == "" {
|
||||
return "", false
|
||||
}
|
||||
if m.ProxyDAO != nil {
|
||||
if list, err := m.ProxyDAO.List(); err == nil {
|
||||
for _, item := range list {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
return strings.TrimSpace(item.ProxyConfig), true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range m.Config.Browser.Proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
return strings.TrimSpace(item.ProxyConfig), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ResolveUserDataDir 解析用户数据目录
|
||||
func (m *Manager) ResolveUserDataDir(profile *Profile) string {
|
||||
userDataDir := strings.TrimSpace(profile.UserDataDir)
|
||||
if userDataDir == "" {
|
||||
userDataDir = profile.ProfileId
|
||||
}
|
||||
if filepath.IsAbs(userDataDir) {
|
||||
return userDataDir
|
||||
}
|
||||
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
|
||||
if root == "" {
|
||||
root = "data"
|
||||
}
|
||||
root = m.ResolveRelativePath(root)
|
||||
return filepath.Join(root, userDataDir)
|
||||
}
|
||||
|
||||
// MigrateConfig 迁移旧配置到新格式
|
||||
func (m *Manager) MigrateConfig() bool {
|
||||
log := logger.New("Browser")
|
||||
|
||||
// 如果存在 environments 但没有 cores,执行迁移
|
||||
if len(m.Config.Browser.Environments) > 0 && len(m.Config.Browser.Cores) == 0 {
|
||||
log.Info("检测到旧配置格式,开始迁移")
|
||||
|
||||
for _, env := range m.Config.Browser.Environments {
|
||||
m.Config.Browser.Cores = append(m.Config.Browser.Cores, Core{
|
||||
CoreId: env.CoreId,
|
||||
CoreName: env.CoreName,
|
||||
CorePath: env.CorePath,
|
||||
IsDefault: env.IsDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// 清空旧字段
|
||||
m.Config.Browser.Environments = nil
|
||||
m.Config.Browser.ChromeBinaryPath = ""
|
||||
m.Config.Browser.CoreRoot = ""
|
||||
m.Config.Browser.DefaultCoreId = ""
|
||||
m.Config.Browser.DefaultConnectorType = ""
|
||||
|
||||
if err := m.Config.Save(m.ResolveRelativePath("config.yaml")); err != nil {
|
||||
log.Error("配置迁移保存失败", logger.F("error", err.Error()))
|
||||
return false
|
||||
}
|
||||
|
||||
log.Info("配置迁移完成", logger.F("cores_count", len(m.Config.Browser.Cores)))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type proxyDAOStub struct {
|
||||
list []Proxy
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *proxyDAOStub) List() ([]Proxy, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return append([]Proxy{}, s.list...), nil
|
||||
}
|
||||
|
||||
func (s *proxyDAOStub) ListByGroup(string) ([]Proxy, error) { return nil, nil }
|
||||
func (s *proxyDAOStub) ListGroups() ([]string, error) { return nil, nil }
|
||||
func (s *proxyDAOStub) Upsert(Proxy) error { return nil }
|
||||
func (s *proxyDAOStub) Delete(string) error { return nil }
|
||||
func (s *proxyDAOStub) DeleteAll() error { return nil }
|
||||
func (s *proxyDAOStub) UpdateSpeedResult(string, bool, int64, string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *proxyDAOStub) UpdateIPHealthResult(string, string) error { return nil }
|
||||
|
||||
func TestGetProxyConfigByIdPreferDAO(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "pool-1", ProxyConfig: "http://127.0.0.1:9999"},
|
||||
}
|
||||
|
||||
mgr := NewManager(cfg, "")
|
||||
mgr.ProxyDAO = &proxyDAOStub{
|
||||
list: []Proxy{
|
||||
{ProxyId: "pool-1", ProxyConfig: "socks5://127.0.0.1:1080"},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := mgr.GetProxyConfigById("pool-1")
|
||||
if !ok {
|
||||
t.Fatalf("expected proxy to be found")
|
||||
}
|
||||
if got != "socks5://127.0.0.1:1080" {
|
||||
t.Fatalf("expected dao proxy config, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProxyConfigByIdFallbackToConfig(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "pool-2", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
|
||||
mgr := NewManager(cfg, "")
|
||||
mgr.ProxyDAO = &proxyDAOStub{err: errors.New("dao unavailable")}
|
||||
|
||||
got, ok := mgr.GetProxyConfigById("pool-2")
|
||||
if !ok {
|
||||
t.Fatalf("expected proxy to be found in config fallback")
|
||||
}
|
||||
if got != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("unexpected proxy config: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GroupDAO 分组数据访问接口
|
||||
type GroupDAO interface {
|
||||
List() ([]*Group, error)
|
||||
GetById(groupId string) (*Group, error)
|
||||
Create(input GroupInput) (*Group, error)
|
||||
Update(groupId string, input GroupInput) (*Group, error)
|
||||
Delete(groupId string) error
|
||||
GetChildren(parentId string) ([]*Group, error)
|
||||
MoveChildren(fromGroupId, toGroupId string) error
|
||||
}
|
||||
|
||||
// SQLiteGroupDAO 基于 SQLite 的 GroupDAO 实现
|
||||
type SQLiteGroupDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteGroupDAO 创建 SQLiteGroupDAO
|
||||
func NewSQLiteGroupDAO(db *sql.DB) *SQLiteGroupDAO {
|
||||
return &SQLiteGroupDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有分组
|
||||
func (d *SQLiteGroupDAO) List() ([]*Group, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT group_id, group_name, parent_id, sort_order, created_at, updated_at
|
||||
FROM browser_groups ORDER BY sort_order ASC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询分组列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Group
|
||||
for rows.Next() {
|
||||
g, err := scanGroup(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, g)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// GetById 根据 groupId 查询单个分组
|
||||
func (d *SQLiteGroupDAO) GetById(groupId string) (*Group, error) {
|
||||
row := d.db.QueryRow(`
|
||||
SELECT group_id, group_name, parent_id, sort_order, created_at, updated_at
|
||||
FROM browser_groups WHERE group_id = ?`, groupId)
|
||||
g, err := scanGroup(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("分组不存在: %s", groupId)
|
||||
}
|
||||
return g, err
|
||||
}
|
||||
|
||||
// Create 创建分组
|
||||
func (d *SQLiteGroupDAO) Create(input GroupInput) (*Group, error) {
|
||||
if input.GroupName == "" {
|
||||
return nil, errors.New("分组名称不能为空")
|
||||
}
|
||||
// 验证父分组存在性
|
||||
if input.ParentId != "" {
|
||||
_, err := d.GetById(input.ParentId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("父分组不存在: %s", input.ParentId)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
group := &Group{
|
||||
GroupId: uuid.New().String(),
|
||||
GroupName: input.GroupName,
|
||||
ParentId: input.ParentId,
|
||||
SortOrder: input.SortOrder,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO browser_groups (group_id, group_name, parent_id, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
group.GroupId, group.GroupName, group.ParentId, group.SortOrder, group.CreatedAt, group.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建分组失败: %w", err)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// Update 更新分组
|
||||
func (d *SQLiteGroupDAO) Update(groupId string, input GroupInput) (*Group, error) {
|
||||
if input.GroupName == "" {
|
||||
return nil, errors.New("分组名称不能为空")
|
||||
}
|
||||
// 检查分组是否存在
|
||||
existing, err := d.GetById(groupId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 验证父分组存在性
|
||||
if input.ParentId != "" {
|
||||
_, err := d.GetById(input.ParentId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("父分组不存在: %s", input.ParentId)
|
||||
}
|
||||
// 检查循环引用
|
||||
if err := d.checkCircularReference(groupId, input.ParentId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
_, err = d.db.Exec(`
|
||||
UPDATE browser_groups SET group_name = ?, parent_id = ?, sort_order = ?, updated_at = ?
|
||||
WHERE group_id = ?`,
|
||||
input.GroupName, input.ParentId, input.SortOrder, now, groupId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新分组失败: %w", err)
|
||||
}
|
||||
|
||||
existing.GroupName = input.GroupName
|
||||
existing.ParentId = input.ParentId
|
||||
existing.SortOrder = input.SortOrder
|
||||
existing.UpdatedAt = now
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// Delete 删除分组(级联处理:子分组和实例移动到父分组)
|
||||
func (d *SQLiteGroupDAO) Delete(groupId string) error {
|
||||
group, err := d.GetById(groupId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 将子分组移动到父分组
|
||||
if err := d.MoveChildren(groupId, group.ParentId); err != nil {
|
||||
return err
|
||||
}
|
||||
// 将该分组下的实例移动到父分组
|
||||
_, err = d.db.Exec(`UPDATE browser_profiles SET group_id = ? WHERE group_id = ?`, group.ParentId, groupId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("移动实例失败: %w", err)
|
||||
}
|
||||
// 删除分组
|
||||
_, err = d.db.Exec(`DELETE FROM browser_groups WHERE group_id = ?`, groupId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除分组失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChildren 获取子分组
|
||||
func (d *SQLiteGroupDAO) GetChildren(parentId string) ([]*Group, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT group_id, group_name, parent_id, sort_order, created_at, updated_at
|
||||
FROM browser_groups WHERE parent_id = ? ORDER BY sort_order ASC`, parentId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询子分组失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Group
|
||||
for rows.Next() {
|
||||
g, err := scanGroup(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, g)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// MoveChildren 将子分组移动到新的父分组
|
||||
func (d *SQLiteGroupDAO) MoveChildren(fromGroupId, toGroupId string) error {
|
||||
_, err := d.db.Exec(`UPDATE browser_groups SET parent_id = ? WHERE parent_id = ?`, toGroupId, fromGroupId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("移动子分组失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCircularReference 检查循环引用
|
||||
func (d *SQLiteGroupDAO) checkCircularReference(groupId, newParentId string) error {
|
||||
if newParentId == groupId {
|
||||
return errors.New("不能将分组设为自己的子分组")
|
||||
}
|
||||
// 遍历祖先链检查是否包含 groupId
|
||||
currentId := newParentId
|
||||
visited := make(map[string]bool)
|
||||
for currentId != "" {
|
||||
if visited[currentId] {
|
||||
return errors.New("检测到循环引用")
|
||||
}
|
||||
visited[currentId] = true
|
||||
if currentId == groupId {
|
||||
return errors.New("不能将分组设为自己的后代分组")
|
||||
}
|
||||
parent, err := d.GetById(currentId)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
currentId = parent.ParentId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanGroup 扫描分组行
|
||||
func scanGroup(s scanner) (*Group, error) {
|
||||
var g Group
|
||||
err := s.Scan(&g.GroupId, &g.GroupName, &g.ParentId, &g.SortOrder, &g.CreatedAt, &g.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// InitData 初始化浏览器数据
|
||||
func (m *Manager) InitData() {
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
if m.Profiles == nil {
|
||||
m.Profiles = make(map[string]*Profile)
|
||||
}
|
||||
if m.BrowserProcesses == nil {
|
||||
m.BrowserProcesses = make(map[string]*exec.Cmd)
|
||||
}
|
||||
if m.XrayBridges == nil {
|
||||
m.XrayBridges = make(map[string]*XrayBridge)
|
||||
}
|
||||
// 执行配置迁移
|
||||
m.MigrateConfig()
|
||||
if len(m.Profiles) > 0 {
|
||||
return
|
||||
}
|
||||
m.loadProfiles()
|
||||
}
|
||||
|
||||
func (m *Manager) loadProfiles() {
|
||||
log := logger.New("Browser")
|
||||
|
||||
// 优先从 DAO(SQLite)加载
|
||||
if m.ProfileDAO != nil {
|
||||
profiles, err := m.ProfileDAO.List()
|
||||
if err != nil {
|
||||
log.Error("从数据库加载实例配置失败", logger.F("error", err))
|
||||
} else {
|
||||
// SQLite 模式:无论是否为空都直接使用,不自动创建默认实例
|
||||
for _, p := range profiles {
|
||||
m.Profiles[p.ProfileId] = p
|
||||
}
|
||||
if len(profiles) > 0 {
|
||||
log.Info("实例配置从数据库加载完成", logger.F("count", len(profiles)))
|
||||
} else {
|
||||
log.Info("实例表为空,用户可手动创建新实例")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级:从 config.yaml 加载(仅在无 SQLite 时使用)
|
||||
if len(m.Config.Browser.Profiles) == 0 {
|
||||
// 不自动创建默认实例,保持空列表
|
||||
log.Info("实例配置为空,用户可手动创建新实例")
|
||||
return
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for _, item := range m.Config.Browser.Profiles {
|
||||
profileId := strings.TrimSpace(item.ProfileId)
|
||||
if profileId == "" {
|
||||
continue
|
||||
}
|
||||
createdAt := strings.TrimSpace(item.CreatedAt)
|
||||
if createdAt == "" {
|
||||
createdAt = now
|
||||
}
|
||||
updatedAt := strings.TrimSpace(item.UpdatedAt)
|
||||
if updatedAt == "" {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
m.Profiles[profileId] = &Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: item.ProfileName,
|
||||
UserDataDir: item.UserDataDir,
|
||||
CoreId: item.CoreId,
|
||||
FingerprintArgs: append([]string{}, item.FingerprintArgs...),
|
||||
ProxyId: item.ProxyId,
|
||||
ProxyConfig: item.ProxyConfig,
|
||||
LaunchArgs: append([]string{}, item.LaunchArgs...),
|
||||
Tags: append([]string{}, item.Tags...),
|
||||
Keywords: append([]string{}, item.Keywords...),
|
||||
Running: false,
|
||||
DebugPort: 0,
|
||||
Pid: 0,
|
||||
LastError: "",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
log.Info("浏览器配置从文件加载完成", logger.F("count", len(m.Profiles)))
|
||||
}
|
||||
|
||||
// SaveProfiles 保存所有实例配置(DAO 模式:逐条 upsert)
|
||||
func (m *Manager) SaveProfiles() error {
|
||||
log := logger.New("Browser")
|
||||
if m.ProfileDAO != nil {
|
||||
for _, profile := range m.Profiles {
|
||||
if err := m.ProfileDAO.Upsert(profile); err != nil {
|
||||
log.Error("实例配置持久化失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Info("实例配置持久化成功", logger.F("count", len(m.Profiles)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级:写回 config.yaml
|
||||
profiles := make([]ProfileConfig, 0, len(m.Profiles))
|
||||
for _, profile := range m.Profiles {
|
||||
profiles = append(profiles, ProfileConfig{
|
||||
ProfileId: profile.ProfileId,
|
||||
ProfileName: profile.ProfileName,
|
||||
UserDataDir: profile.UserDataDir,
|
||||
CoreId: profile.CoreId,
|
||||
FingerprintArgs: append([]string{}, profile.FingerprintArgs...),
|
||||
ProxyId: profile.ProxyId,
|
||||
ProxyConfig: profile.ProxyConfig,
|
||||
LaunchArgs: append([]string{}, profile.LaunchArgs...),
|
||||
Tags: append([]string{}, profile.Tags...),
|
||||
Keywords: append([]string{}, profile.Keywords...),
|
||||
CreatedAt: profile.CreatedAt,
|
||||
UpdatedAt: profile.UpdatedAt,
|
||||
})
|
||||
}
|
||||
m.Config.Browser.Profiles = profiles
|
||||
if err := m.Config.Save(m.ResolveRelativePath("config.yaml")); err != nil {
|
||||
log.Error("浏览器配置持久化失败", logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
log.Info("浏览器配置持久化成功(文件)", logger.F("count", len(profiles)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 获取配置列表
|
||||
func (m *Manager) List() []Profile {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
list := make([]Profile, 0, len(m.Profiles))
|
||||
for _, profile := range m.Profiles {
|
||||
p := *profile
|
||||
if m.CodeProvider != nil {
|
||||
if code, err := m.CodeProvider.EnsureCode(p.ProfileId); err == nil {
|
||||
p.LaunchCode = code
|
||||
}
|
||||
}
|
||||
list = append(list, p)
|
||||
}
|
||||
// 按 ProfileId 排序,保持稳定顺序
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].ProfileId < list[j].ProfileId
|
||||
})
|
||||
log.Info("浏览器配置列表查询", logger.F("count", len(list)))
|
||||
return list
|
||||
}
|
||||
|
||||
// ListByTag 按标签筛选配置列表
|
||||
func (m *Manager) ListByTag(tag string) []Profile {
|
||||
tag = strings.TrimSpace(tag)
|
||||
all := m.List()
|
||||
if tag == "" {
|
||||
return all
|
||||
}
|
||||
result := make([]Profile, 0)
|
||||
for _, p := range all {
|
||||
for _, t := range p.Tags {
|
||||
if strings.EqualFold(t, tag) {
|
||||
result = append(result, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllTags 获取所有已使用的标签(去重排序)
|
||||
func (m *Manager) GetAllTags() []string {
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range m.Profiles {
|
||||
for _, t := range p.Tags {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
seen[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
tags := make([]string, 0, len(seen))
|
||||
for t := range seen {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
sort.Strings(tags)
|
||||
return tags
|
||||
}
|
||||
|
||||
// Create 创建配置
|
||||
func (m *Manager) Create(input ProfileInput) (*Profile, error) {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
|
||||
// Check Profile Limit
|
||||
if m.Config.App.MaxProfileLimit > 0 && len(m.Profiles) >= m.Config.App.MaxProfileLimit {
|
||||
return nil, fmt.Errorf("实例数量已达上限 (%d个),无法创建新的实例。请兑换额度后重试!", m.Config.App.MaxProfileLimit)
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
profileId := uuid.NewString()
|
||||
userDataDir := strings.TrimSpace(input.UserDataDir)
|
||||
if userDataDir == "" {
|
||||
userDataDir = profileId
|
||||
}
|
||||
proxyConfig := strings.TrimSpace(input.ProxyConfig)
|
||||
proxyId := strings.TrimSpace(input.ProxyId)
|
||||
if proxyId != "" {
|
||||
if resolved, ok := m.GetProxyConfigById(proxyId); ok {
|
||||
proxyConfig = resolved
|
||||
} else {
|
||||
log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", proxyId))
|
||||
}
|
||||
}
|
||||
coreId := strings.TrimSpace(input.CoreId)
|
||||
if coreId == "" {
|
||||
if defaultCore, ok := m.GetDefaultCore(); ok {
|
||||
coreId = defaultCore.CoreId
|
||||
}
|
||||
}
|
||||
if proxyConfig == "" && m.Config.Browser.DefaultProxy != "" {
|
||||
proxyConfig = m.Config.Browser.DefaultProxy
|
||||
}
|
||||
profile := &Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: input.ProfileName,
|
||||
UserDataDir: userDataDir,
|
||||
CoreId: coreId,
|
||||
FingerprintArgs: input.FingerprintArgs,
|
||||
ProxyId: proxyId,
|
||||
ProxyConfig: proxyConfig,
|
||||
LaunchArgs: input.LaunchArgs,
|
||||
Tags: input.Tags,
|
||||
Keywords: append([]string{}, input.Keywords...),
|
||||
GroupId: strings.TrimSpace(input.GroupId),
|
||||
Running: false,
|
||||
DebugPort: 0,
|
||||
Pid: 0,
|
||||
LastError: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
m.Profiles[profileId] = profile
|
||||
log.Info("浏览器配置创建", logger.F("profile_id", profileId), logger.F("profile_name", input.ProfileName))
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.CodeProvider != nil {
|
||||
if code, err := m.CodeProvider.EnsureCode(profile.ProfileId); err == nil {
|
||||
profile.LaunchCode = code
|
||||
}
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// Update 更新配置
|
||||
func (m *Manager) Update(profileId string, input ProfileInput) (*Profile, error) {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
profile, exists := m.Profiles[profileId]
|
||||
if !exists {
|
||||
log.Error("浏览器配置不存在", logger.F("profile_id", profileId))
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
profile.ProfileName = input.ProfileName
|
||||
profile.UserDataDir = input.UserDataDir
|
||||
profile.CoreId = input.CoreId
|
||||
profile.FingerprintArgs = input.FingerprintArgs
|
||||
profile.ProxyId = strings.TrimSpace(input.ProxyId)
|
||||
if profile.ProxyId != "" {
|
||||
if resolved, ok := m.GetProxyConfigById(profile.ProxyId); ok {
|
||||
profile.ProxyConfig = resolved
|
||||
} else {
|
||||
profile.ProxyConfig = ""
|
||||
log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", profile.ProxyId))
|
||||
}
|
||||
} else {
|
||||
profile.ProxyConfig = input.ProxyConfig
|
||||
}
|
||||
profile.LaunchArgs = input.LaunchArgs
|
||||
profile.Tags = input.Tags
|
||||
profile.Keywords = append([]string{}, input.Keywords...)
|
||||
profile.GroupId = strings.TrimSpace(input.GroupId)
|
||||
profile.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
log.Info("浏览器配置更新", logger.F("profile_id", profileId), logger.F("profile_name", input.ProfileName))
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// Delete 删除配置
|
||||
func (m *Manager) Delete(profileId string) error {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
if _, exists := m.Profiles[profileId]; !exists {
|
||||
log.Error("浏览器配置不存在", logger.F("profile_id", profileId))
|
||||
return fmt.Errorf("profile not found")
|
||||
}
|
||||
delete(m.Profiles, profileId)
|
||||
log.Info("浏览器配置删除", logger.F("profile_id", profileId))
|
||||
|
||||
// DAO 删除
|
||||
if m.ProfileDAO != nil {
|
||||
if err := m.ProfileDAO.Delete(profileId); err != nil {
|
||||
log.Error("数据库删除实例失败", logger.F("profile_id", profileId), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.CodeProvider != nil {
|
||||
_ = m.CodeProvider.Remove(profileId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyDefaults 应用默认配置
|
||||
func (m *Manager) ApplyDefaults(profile *Profile) bool {
|
||||
log := logger.New("Browser")
|
||||
if profile.FingerprintArgs == nil || len(profile.FingerprintArgs) == 0 {
|
||||
profile.FingerprintArgs = append([]string{}, m.Config.Browser.DefaultFingerprintArgs...)
|
||||
}
|
||||
if profile.LaunchArgs == nil || len(profile.LaunchArgs) == 0 {
|
||||
profile.LaunchArgs = append([]string{}, m.Config.Browser.DefaultLaunchArgs...)
|
||||
}
|
||||
if strings.TrimSpace(profile.UserDataDir) == "" {
|
||||
profile.UserDataDir = profile.ProfileId
|
||||
}
|
||||
if strings.TrimSpace(profile.CoreId) == "" {
|
||||
if defaultCore, ok := m.GetDefaultCore(); ok {
|
||||
profile.CoreId = defaultCore.CoreId
|
||||
}
|
||||
}
|
||||
proxyChanged := false
|
||||
if profile.ProxyId != "" {
|
||||
if proxyConfig, ok := m.GetProxyConfigById(profile.ProxyId); ok {
|
||||
if proxyConfig != "" && profile.ProxyConfig != proxyConfig {
|
||||
profile.ProxyConfig = proxyConfig
|
||||
proxyChanged = true
|
||||
}
|
||||
} else {
|
||||
log.Error("实例代理未找到", logger.F("profile_id", profile.ProfileId), logger.F("proxy_id", profile.ProxyId))
|
||||
}
|
||||
}
|
||||
if profile.ProxyConfig == "" && m.Config.Browser.DefaultProxy != "" {
|
||||
profile.ProxyConfig = m.Config.Browser.DefaultProxy
|
||||
proxyChanged = true
|
||||
}
|
||||
return proxyChanged
|
||||
}
|
||||
|
||||
// Copy 复制实例配置(除指纹参数外全部复制,指纹使用默认值生成新种子)
|
||||
func (m *Manager) Copy(profileId string, newName string) (*Profile, error) {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
|
||||
// Check Profile Limit
|
||||
if m.Config.App.MaxProfileLimit > 0 && len(m.Profiles) >= m.Config.App.MaxProfileLimit {
|
||||
log.Error("复制实例失败: 达到数量上限", logger.F("limit", m.Config.App.MaxProfileLimit))
|
||||
return nil, fmt.Errorf("实例数量已达上限 (%d个),无法复制实例。请兑换额度后重试!", m.Config.App.MaxProfileLimit)
|
||||
}
|
||||
|
||||
src, exists := m.Profiles[profileId]
|
||||
if !exists {
|
||||
log.Error("源实例不存在", logger.F("profile_id", profileId))
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
newId := uuid.NewString()
|
||||
|
||||
// 处理名称
|
||||
profileName := strings.TrimSpace(newName)
|
||||
if profileName == "" {
|
||||
profileName = src.ProfileName + " (副本)"
|
||||
}
|
||||
|
||||
// 复制配置,指纹参数使用默认值(新种子)
|
||||
profile := &Profile{
|
||||
ProfileId: newId,
|
||||
ProfileName: profileName,
|
||||
UserDataDir: newId, // 新的用户数据目录
|
||||
CoreId: src.CoreId,
|
||||
FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...), // 使用默认指纹(新种子)
|
||||
ProxyId: src.ProxyId,
|
||||
ProxyConfig: src.ProxyConfig,
|
||||
LaunchArgs: append([]string{}, src.LaunchArgs...),
|
||||
Tags: append([]string{}, src.Tags...),
|
||||
Keywords: append([]string{}, src.Keywords...),
|
||||
GroupId: src.GroupId, // 复制分组
|
||||
Running: false,
|
||||
DebugPort: 0,
|
||||
Pid: 0,
|
||||
LastError: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
m.Profiles[newId] = profile
|
||||
log.Info("实例复制成功", logger.F("src_id", profileId), logger.F("new_id", newId), logger.F("new_name", profileName))
|
||||
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.CodeProvider != nil {
|
||||
if code, err := m.CodeProvider.EnsureCode(profile.ProfileId); err == nil {
|
||||
profile.LaunchCode = code
|
||||
}
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// SetKeywords 设置实例关键字(独立接口,不影响其他字段)
|
||||
func (m *Manager) SetKeywords(profileId string, keywords []string) (*Profile, error) {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
profile, exists := m.Profiles[profileId]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
profile.Keywords = append([]string{}, keywords...)
|
||||
profile.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
log.Info("关键字更新", logger.F("profile_id", profileId))
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// copyKeywords 深拷贝 keywords map
|
||||
func copyKeywords(src map[string]string) map[string]string {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProfileDAO 实例配置持久化接口
|
||||
type ProfileDAO interface {
|
||||
List() ([]*Profile, error)
|
||||
GetById(profileId string) (*Profile, error)
|
||||
Upsert(profile *Profile) error
|
||||
Delete(profileId string) error
|
||||
}
|
||||
|
||||
// SQLiteProfileDAO 基于 SQLite 的 ProfileDAO 实现
|
||||
type SQLiteProfileDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteProfileDAO 创建 SQLiteProfileDAO
|
||||
func NewSQLiteProfileDAO(db *sql.DB) *SQLiteProfileDAO {
|
||||
return &SQLiteProfileDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有实例配置,按创建时间升序
|
||||
func (d *SQLiteProfileDAO) List() ([]*Profile, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT 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
|
||||
FROM browser_profiles ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询实例列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Profile
|
||||
for rows.Next() {
|
||||
p, err := scanProfile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, p)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// GetById 根据 profileId 查询单个实例
|
||||
func (d *SQLiteProfileDAO) GetById(profileId string) (*Profile, error) {
|
||||
row := d.db.QueryRow(`
|
||||
SELECT 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
|
||||
FROM browser_profiles WHERE profile_id = ?`, profileId)
|
||||
p, err := scanProfile(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("实例不存在: %s", profileId)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// Upsert 新增或更新实例配置
|
||||
func (d *SQLiteProfileDAO) Upsert(profile *Profile) error {
|
||||
fingerprintArgs, _ := json.Marshal(profile.FingerprintArgs)
|
||||
launchArgs, _ := json.Marshal(profile.LaunchArgs)
|
||||
tags, _ := json.Marshal(profile.Tags)
|
||||
keywords, _ := json.Marshal(profile.Keywords)
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if profile.CreatedAt == "" {
|
||||
profile.CreatedAt = now
|
||||
}
|
||||
if profile.UpdatedAt == "" {
|
||||
profile.UpdatedAt = now
|
||||
}
|
||||
|
||||
_, err := d.db.Exec(`
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
profile_name = excluded.profile_name,
|
||||
user_data_dir = excluded.user_data_dir,
|
||||
core_id = excluded.core_id,
|
||||
fingerprint_args = excluded.fingerprint_args,
|
||||
proxy_id = excluded.proxy_id,
|
||||
proxy_config = excluded.proxy_config,
|
||||
launch_args = excluded.launch_args,
|
||||
tags = excluded.tags,
|
||||
keywords = excluded.keywords,
|
||||
group_id = excluded.group_id,
|
||||
updated_at = excluded.updated_at`,
|
||||
profile.ProfileId, profile.ProfileName, profile.UserDataDir, profile.CoreId,
|
||||
string(fingerprintArgs), profile.ProxyId, profile.ProxyConfig,
|
||||
string(launchArgs), string(tags), string(keywords), profile.GroupId,
|
||||
profile.CreatedAt, profile.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存实例配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除实例配置
|
||||
func (d *SQLiteProfileDAO) Delete(profileId string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM browser_profiles WHERE profile_id = ?`, profileId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除实例配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByGroup 按分组筛选实例
|
||||
// groupId 为空字符串时返回未分组的实例
|
||||
// includeChildren=true 时同时包含 childGroupIds 中的子分组实例
|
||||
func (d *SQLiteProfileDAO) ListByGroup(groupId string, includeChildren bool, childGroupIds []string) ([]*Profile, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if includeChildren && len(childGroupIds) > 0 {
|
||||
// 构建 IN 子句,包含当前分组和所有子分组
|
||||
allIds := append([]string{groupId}, childGroupIds...)
|
||||
inClause := ""
|
||||
args := make([]interface{}, len(allIds))
|
||||
for i, id := range allIds {
|
||||
if i > 0 {
|
||||
inClause += ","
|
||||
}
|
||||
inClause += "?"
|
||||
args[i] = id
|
||||
}
|
||||
rows, err = d.db.Query(fmt.Sprintf(`
|
||||
SELECT 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
|
||||
FROM browser_profiles WHERE group_id IN (%s) ORDER BY created_at ASC`, inClause), args...)
|
||||
} else {
|
||||
// 仅查询指定分组
|
||||
rows, err = d.db.Query(`
|
||||
SELECT 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
|
||||
FROM browser_profiles WHERE group_id = ? ORDER BY created_at ASC`, groupId)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("按分组查询实例失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Profile
|
||||
for rows.Next() {
|
||||
p, err := scanProfile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, p)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// MoveToGroup 批量移动实例到分组
|
||||
func (d *SQLiteProfileDAO) MoveToGroup(profileIds []string, groupId string) error {
|
||||
if len(profileIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
inClause := ""
|
||||
args := make([]interface{}, len(profileIds)+1)
|
||||
args[0] = groupId
|
||||
for i, id := range profileIds {
|
||||
if i > 0 {
|
||||
inClause += ","
|
||||
}
|
||||
inClause += "?"
|
||||
args[i+1] = id
|
||||
}
|
||||
_, err := d.db.Exec(fmt.Sprintf(`UPDATE browser_profiles SET group_id = ? WHERE profile_id IN (%s)`, inClause), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("批量移动实例失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanner 统一扫描接口,兼容 *sql.Row 和 *sql.Rows
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanProfile(s scanner) (*Profile, error) {
|
||||
var (
|
||||
fingerprintArgsJSON, launchArgsJSON, tagsJSON, keywordsJSON string
|
||||
p Profile
|
||||
)
|
||||
err := s.Scan(
|
||||
&p.ProfileId, &p.ProfileName, &p.UserDataDir, &p.CoreId,
|
||||
&fingerprintArgsJSON, &p.ProxyId, &p.ProxyConfig,
|
||||
&launchArgsJSON, &tagsJSON, &keywordsJSON, &p.GroupId,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(fingerprintArgsJSON), &p.FingerprintArgs)
|
||||
_ = json.Unmarshal([]byte(launchArgsJSON), &p.LaunchArgs)
|
||||
_ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
|
||||
_ = json.Unmarshal([]byte(keywordsJSON), &p.Keywords)
|
||||
if p.FingerprintArgs == nil {
|
||||
p.FingerprintArgs = []string{}
|
||||
}
|
||||
if p.LaunchArgs == nil {
|
||||
p.LaunchArgs = []string{}
|
||||
}
|
||||
if p.Tags == nil {
|
||||
p.Tags = []string{}
|
||||
}
|
||||
if p.Keywords == nil {
|
||||
p.Keywords = []string{}
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProxyDAO 代理列表持久化接口
|
||||
type ProxyDAO interface {
|
||||
List() ([]Proxy, error)
|
||||
ListByGroup(groupName string) ([]Proxy, error)
|
||||
ListGroups() ([]string, error)
|
||||
Upsert(proxy Proxy) error
|
||||
Delete(proxyId string) error
|
||||
DeleteAll() error
|
||||
UpdateSpeedResult(proxyId string, ok bool, latencyMs int64, testedAt string) error
|
||||
UpdateIPHealthResult(proxyId string, healthJSON string) error
|
||||
}
|
||||
|
||||
// SQLiteProxyDAO 基于 SQLite 的 ProxyDAO 实现
|
||||
type SQLiteProxyDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteProxyDAO 创建 SQLiteProxyDAO
|
||||
func NewSQLiteProxyDAO(db *sql.DB) *SQLiteProxyDAO {
|
||||
return &SQLiteProxyDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有代理,按 sort_order 升序
|
||||
func (d *SQLiteProxyDAO) List() ([]Proxy, error) {
|
||||
rows, err := d.db.Query(`
|
||||
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
|
||||
FROM browser_proxies ORDER BY sort_order ASC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询代理列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProxies(rows)
|
||||
}
|
||||
|
||||
// ListByGroup 按分组名称查询代理
|
||||
func (d *SQLiteProxyDAO) ListByGroup(groupName string) ([]Proxy, error) {
|
||||
rows, err := d.db.Query(`
|
||||
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
|
||||
FROM browser_proxies WHERE group_name = ?
|
||||
ORDER BY sort_order ASC, created_at ASC`, groupName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("按分组查询代理失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProxies(rows)
|
||||
}
|
||||
|
||||
// ListGroups 获取所有非空分组名称(去重)
|
||||
func (d *SQLiteProxyDAO) ListGroups() ([]string, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT DISTINCT group_name FROM browser_proxies
|
||||
WHERE group_name != '' ORDER BY group_name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询代理分组失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []string
|
||||
for rows.Next() {
|
||||
var g string
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert 新增或更新代理
|
||||
func (d *SQLiteProxyDAO) Upsert(proxy Proxy) error {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
autoRefreshInt := 0
|
||||
if proxy.SourceAutoRefresh {
|
||||
autoRefreshInt = 1
|
||||
}
|
||||
_, err := d.db.Exec(`
|
||||
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,
|
||||
sort_order, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(proxy_id) DO UPDATE SET
|
||||
proxy_name = excluded.proxy_name,
|
||||
proxy_config = excluded.proxy_config,
|
||||
dns_servers = excluded.dns_servers,
|
||||
group_name = excluded.group_name,
|
||||
source_id = excluded.source_id,
|
||||
source_url = excluded.source_url,
|
||||
source_name_prefix = excluded.source_name_prefix,
|
||||
source_auto_refresh = excluded.source_auto_refresh,
|
||||
source_refresh_interval_m = excluded.source_refresh_interval_m,
|
||||
source_last_refresh_at = excluded.source_last_refresh_at,
|
||||
sort_order = excluded.sort_order`,
|
||||
proxy.ProxyId, proxy.ProxyName, proxy.ProxyConfig, proxy.DnsServers, proxy.GroupName,
|
||||
proxy.SourceID, proxy.SourceURL, proxy.SourceNamePrefix, autoRefreshInt, proxy.SourceRefreshIntervalM, proxy.SourceLastRefreshAt,
|
||||
proxy.SortOrder, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存代理失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除单个代理
|
||||
func (d *SQLiteProxyDAO) Delete(proxyId string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM browser_proxies WHERE proxy_id = ?`, proxyId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除代理失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAll 清空代理表(批量保存前使用)
|
||||
func (d *SQLiteProxyDAO) DeleteAll() error {
|
||||
_, err := d.db.Exec(`DELETE FROM browser_proxies`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("清空代理表失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSpeedResult 更新单个代理的测速结果
|
||||
func (d *SQLiteProxyDAO) UpdateSpeedResult(proxyId string, ok bool, latencyMs int64, testedAt string) error {
|
||||
okInt := 0
|
||||
if ok {
|
||||
okInt = 1
|
||||
}
|
||||
_, err := d.db.Exec(`
|
||||
UPDATE browser_proxies SET last_latency_ms=?, last_test_ok=?, last_tested_at=?
|
||||
WHERE proxy_id=?`, latencyMs, okInt, testedAt, proxyId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新测速结果失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateIPHealthResult 更新单个代理的 IP 健康检测结果(JSON 字符串)
|
||||
func (d *SQLiteProxyDAO) UpdateIPHealthResult(proxyId string, healthJSON string) error {
|
||||
_, err := d.db.Exec(`
|
||||
UPDATE browser_proxies SET last_ip_health_json=?
|
||||
WHERE proxy_id=?`, healthJSON, proxyId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 IP 健康结果失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanProxies(rows *sql.Rows) ([]Proxy, error) {
|
||||
var list []Proxy
|
||||
for rows.Next() {
|
||||
var p Proxy
|
||||
var okInt int
|
||||
var autoRefreshInt int
|
||||
if err := rows.Scan(
|
||||
&p.ProxyId, &p.ProxyName, &p.ProxyConfig, &p.DnsServers, &p.GroupName,
|
||||
&p.SourceID, &p.SourceURL, &p.SourceNamePrefix, &autoRefreshInt, &p.SourceRefreshIntervalM, &p.SourceLastRefreshAt,
|
||||
&p.LastLatencyMs, &okInt, &p.LastTestedAt, &p.LastIPHealthJSON, &p.SortOrder,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("读取代理行失败: %w", err)
|
||||
}
|
||||
p.LastTestOk = okInt == 1
|
||||
p.SourceAutoRefresh = autoRefreshInt == 1
|
||||
list = append(list, p)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SpeedTestFunc 执行单个代理测速的函数类型
|
||||
type SpeedTestFunc func(proxyId string) (ok bool, latencyMs int64, err string)
|
||||
|
||||
// ProxySpeedScheduler 代理测速定时调度器
|
||||
type ProxySpeedScheduler struct {
|
||||
dao ProxyDAO
|
||||
testFn SpeedTestFunc
|
||||
interval time.Duration
|
||||
concLimit int
|
||||
stopCh chan struct{}
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewProxySpeedScheduler 创建调度器,interval 为测速间隔,concLimit 为并发数
|
||||
func NewProxySpeedScheduler(dao ProxyDAO, testFn SpeedTestFunc, interval time.Duration, concLimit int) *ProxySpeedScheduler {
|
||||
if concLimit <= 0 {
|
||||
concLimit = 5
|
||||
}
|
||||
return &ProxySpeedScheduler{
|
||||
dao: dao,
|
||||
testFn: testFn,
|
||||
interval: interval,
|
||||
concLimit: concLimit,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动定时任务(非阻塞)
|
||||
func (s *ProxySpeedScheduler) Start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.running {
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
go s.loop()
|
||||
}
|
||||
|
||||
// Stop 停止定时任务
|
||||
func (s *ProxySpeedScheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
// RunOnce 立即执行一轮测速(可手动触发)
|
||||
func (s *ProxySpeedScheduler) RunOnce() {
|
||||
go s.runAll()
|
||||
}
|
||||
|
||||
func (s *ProxySpeedScheduler) loop() {
|
||||
// 启动后延迟 10s 跑第一轮,避免影响启动速度
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
s.runAll()
|
||||
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.runAll()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ProxySpeedScheduler) runAll() {
|
||||
proxies, err := s.dao.List()
|
||||
if err != nil || len(proxies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, s.concLimit)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, p := range proxies {
|
||||
// 跳过直连(无意义测速)
|
||||
if p.ProxyConfig == "direct://" {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(proxyId string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
ok, latencyMs, _ := s.testFn(proxyId)
|
||||
testedAt := time.Now().Format(time.RFC3339)
|
||||
_ = s.dao.UpdateSpeedResult(proxyId, ok, latencyMs, testedAt)
|
||||
}(p.ProxyId)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Profile 浏览器配置文件
|
||||
type Profile struct {
|
||||
ProfileId string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
UserDataDir string `json:"userDataDir"`
|
||||
CoreId string `json:"coreId"`
|
||||
FingerprintArgs []string `json:"fingerprintArgs"`
|
||||
ProxyId string `json:"proxyId"`
|
||||
ProxyConfig string `json:"proxyConfig"`
|
||||
LaunchArgs []string `json:"launchArgs"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords []string `json:"keywords"`
|
||||
GroupId string `json:"groupId"` // 所属分组ID
|
||||
LaunchCode string `json:"launchCode"`
|
||||
Running bool `json:"running"`
|
||||
DebugPort int `json:"debugPort"`
|
||||
Pid int `json:"pid"`
|
||||
LastError string `json:"lastError"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
LastStartAt string `json:"lastStartAt"`
|
||||
LastStopAt string `json:"lastStopAt"`
|
||||
}
|
||||
|
||||
// ProfileInput 创建/更新配置文件的输入
|
||||
type ProfileInput struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
UserDataDir string `json:"userDataDir"`
|
||||
CoreId string `json:"coreId"`
|
||||
FingerprintArgs []string `json:"fingerprintArgs"`
|
||||
ProxyId string `json:"proxyId"`
|
||||
ProxyConfig string `json:"proxyConfig"`
|
||||
LaunchArgs []string `json:"launchArgs"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords []string `json:"keywords"`
|
||||
GroupId string `json:"groupId"` // 所属分组ID
|
||||
}
|
||||
|
||||
// Tab 浏览器标签页
|
||||
type Tab struct {
|
||||
TabId string `json:"tabId"`
|
||||
Title string `json:"title"`
|
||||
Url string `json:"url"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// Settings 浏览器全局设置
|
||||
type Settings struct {
|
||||
UserDataRoot string `json:"userDataRoot"`
|
||||
DefaultFingerprintArgs []string `json:"defaultFingerprintArgs"`
|
||||
DefaultLaunchArgs []string `json:"defaultLaunchArgs"`
|
||||
DefaultProxy string `json:"defaultProxy"`
|
||||
}
|
||||
|
||||
// CoreInput 内核配置输入
|
||||
type CoreInput struct {
|
||||
CoreId string `json:"coreId"`
|
||||
CoreName string `json:"coreName"`
|
||||
CorePath string `json:"corePath"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
// CoreValidateResult 内核路径验证结果
|
||||
type CoreValidateResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CoreExtendedInfo 内核扩展信息
|
||||
type CoreExtendedInfo struct {
|
||||
CoreId string `json:"coreId"`
|
||||
ChromeVersion string `json:"chromeVersion"`
|
||||
InstanceCount int `json:"instanceCount"`
|
||||
}
|
||||
|
||||
// Group 实例分组
|
||||
type Group struct {
|
||||
GroupId string `json:"groupId"`
|
||||
GroupName string `json:"groupName"`
|
||||
ParentId string `json:"parentId"` // 空字符串表示根级分组
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// GroupInput 创建/更新分组的输入
|
||||
type GroupInput struct {
|
||||
GroupName string `json:"groupName"`
|
||||
ParentId string `json:"parentId"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
// GroupWithCount 带实例计数的分组
|
||||
type GroupWithCount struct {
|
||||
Group
|
||||
InstanceCount int `json:"instanceCount"`
|
||||
}
|
||||
|
||||
// 类型别名
|
||||
type Proxy = config.BrowserProxy
|
||||
type Core = config.BrowserCore
|
||||
type Environment = config.BrowserEnvironment
|
||||
type ProfileConfig = config.BrowserProfileConfig
|
||||
|
||||
// CodeProvider 提供 LaunchCode 的接口(由 launchcode.LaunchCodeService 实现)
|
||||
type CodeProvider interface {
|
||||
EnsureCode(profileId string) (string, error)
|
||||
Remove(profileId string) error
|
||||
}
|
||||
|
||||
// Manager 浏览器管理器
|
||||
type Manager struct {
|
||||
Config *config.Config
|
||||
AppRoot string // 应用根目录,所有相对路径基于此解析(生产=exe目录,dev=项目根目录)
|
||||
Profiles map[string]*Profile
|
||||
Mutex sync.Mutex
|
||||
BrowserProcesses map[string]*exec.Cmd
|
||||
XrayBridges map[string]*XrayBridge
|
||||
CodeProvider CodeProvider
|
||||
|
||||
// DAO 层(注入后使用 SQLite 存储,未注入时降级到 config.yaml)
|
||||
ProfileDAO ProfileDAO
|
||||
ProxyDAO ProxyDAO
|
||||
CoreDAO CoreDAO
|
||||
BookmarkDAO BookmarkDAO
|
||||
GroupDAO GroupDAO
|
||||
}
|
||||
|
||||
// XrayBridge Xray 桥接进程
|
||||
type XrayBridge struct {
|
||||
NodeKey string
|
||||
Port int
|
||||
Cmd *exec.Cmd
|
||||
Pid int
|
||||
Running bool
|
||||
LastError string
|
||||
}
|
||||
|
||||
// NewManager 创建浏览器管理器
|
||||
func NewManager(cfg *config.Config, appRoot string) *Manager {
|
||||
return &Manager{
|
||||
Config: cfg,
|
||||
AppRoot: appRoot,
|
||||
Profiles: make(map[string]*Profile),
|
||||
BrowserProcesses: make(map[string]*exec.Cmd),
|
||||
XrayBridges: make(map[string]*XrayBridge),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRelativePath 将相对路径解析为绝对路径(基于 AppRoot)。
|
||||
// 如果传入的已经是绝对路径则直接返回。
|
||||
func (m *Manager) ResolveRelativePath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
if m.AppRoot != "" {
|
||||
return filepath.Join(m.AppRoot, p)
|
||||
}
|
||||
// 兜底:使用 CWD
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
return filepath.Join(cwd, p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
Reference in New Issue
Block a user