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:
Ant Browser Release Bot
2026-03-13 23:19:29 +08:00
commit 6f58a6c19a
230 changed files with 44624 additions and 0 deletions
+69
View File
@@ -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()
}
+223
View File
@@ -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,
)
}
+6
View File
@@ -0,0 +1,6 @@
package browser
// BuildLaunchArgs 构建启动参数
func BuildLaunchArgs(args []string, profile *Profile) []string {
return args
}
+344
View File
@@ -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
}
+96
View File
@@ -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()
}
+460
View File
@@ -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
}
+83
View File
@@ -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)
}
}
+223
View File
@@ -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
}
+471
View File
@@ -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")
// 优先从 DAOSQLite)加载
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
}
+225
View File
@@ -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
}
+185
View File
@@ -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()
}
+110
View File
@@ -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()
}
+174
View File
@@ -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
}