Files
Ant-Browser/backend/app.go
T
Ant Browser Release Bot 3d264eb83d publish: 1.1.0 snapshot (a358335)
channel: master
version: 1.1.0
source-ref: master
published-at-utc: 2026-03-29T11:32:04Z
2026-03-29 19:32:05 +08:00

1297 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backend
import (
"ant-chrome/backend/internal/apppath"
"ant-chrome/backend/internal/browser"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/database"
"ant-chrome/backend/internal/launchcode"
"ant-chrome/backend/internal/logger"
"ant-chrome/backend/internal/proxy"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type quitMode uint8
const (
quitModeFull quitMode = iota
quitModeAppOnly
)
// App 应用结构体
type App struct {
ctx context.Context
config *config.Config
db *database.DB
interceptor *logger.MethodInterceptor
browserMgr *browser.Manager
xrayMgr *proxy.XrayManager
clashMgr *proxy.ClashManager
singboxMgr *proxy.SingBoxManager
launchCodeSvc *launchcode.LaunchCodeService
launchServer *launchcode.LaunchServer
speedScheduler *browser.ProxySpeedScheduler
appRoot string
version string
forceQuit bool // 强制退出标志,用于跳过 OnBeforeClose 的拦截
quitMode quitMode // 退出模式:全量退出 / 仅退出应用
maintenanceMu sync.Mutex // 维护类操作(初始化/导入/导出)互斥锁
bridgeMu sync.Mutex
xrayBridgeRefs map[string]string
stopServicesOnce sync.Once
finalizeOnce sync.Once
}
// NewApp 创建新的应用实例
func NewApp(appRoot string, appVersion ...string) *App {
version := ""
if len(appVersion) > 0 {
version = strings.TrimSpace(appVersion[0])
}
return &App{
appRoot: strings.TrimSpace(appRoot),
version: version,
xrayBridgeRefs: make(map[string]string),
}
}
func (a *App) appName() string {
if a.config != nil {
if name := strings.TrimSpace(a.config.App.Name); name != "" {
return name
}
}
return "Ant Browser"
}
func (a *App) appVersion() string {
version := strings.TrimSpace(a.version)
if version == "" {
return "unknown"
}
return version
}
// startup 应用启动时调用
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
if err := apppath.EnsureWritableLayout(a.appRoot); err != nil {
runtime.LogFatal(ctx, fmt.Sprintf("初始化 Linux 用户数据目录失败: %v", err))
return
}
cfg, err := LoadConfig(a.resolveAppPath("config.yaml"))
if err != nil {
cfg = config.DefaultConfig()
}
a.config = cfg
a.applyRuntimeConfig(cfg.Runtime)
logConfig := logger.LoggerConfig{
Level: cfg.Logging.Level,
FileEnabled: cfg.Logging.FileEnabled,
FilePath: a.resolveAppPath(cfg.Logging.FilePath),
Format: cfg.Logging.Format,
BufferSize: cfg.Logging.BufferSize,
AsyncQueueSize: cfg.Logging.AsyncQueueSize,
FlushIntervalMs: cfg.Logging.FlushIntervalMs,
Rotation: logger.RotationConfig{
Enabled: cfg.Logging.Rotation.Enabled,
MaxSizeMB: cfg.Logging.Rotation.MaxSizeMB,
MaxAge: cfg.Logging.Rotation.MaxAge,
MaxBackups: cfg.Logging.Rotation.MaxBackups,
TimeInterval: cfg.Logging.Rotation.TimeInterval,
},
}
logger.InitWithConfig(ctx, logConfig)
log := logger.New("App")
log.Info("应用启动中...",
logger.F("version", a.appVersion()),
logger.F("max_memory_mb", cfg.Runtime.MaxMemoryMB),
logger.F("gc_percent", cfg.Runtime.GCPercent),
)
if apppath.IsDetached(a.appRoot) {
log.Info("检测到安装目录需要只读运行,已切换到用户数据目录",
logger.F("install_root", apppath.InstallRoot(a.appRoot)),
logger.F("state_root", apppath.StateRoot(a.appRoot)),
)
}
// 确保 data 目录存在(存放数据库、用户数据、快照等)
if err := os.MkdirAll(a.resolveAppPath("data"), 0755); err != nil {
log.Error("创建 data 目录失败", logger.F("error", err))
}
a.ensureDefaultCores()
if cfg.Logging.Interceptor.Enabled {
interceptorConfig := logger.InterceptorConfig{
Enabled: cfg.Logging.Interceptor.Enabled,
LogParameters: cfg.Logging.Interceptor.LogParameters,
LogResults: cfg.Logging.Interceptor.LogResults,
SensitiveFields: cfg.Logging.Interceptor.SensitiveFields,
}
a.interceptor = logger.NewMethodInterceptor(log, interceptorConfig)
}
db, err := database.NewDB(a.resolveAppPath(cfg.Database.SQLite.Path))
if err != nil {
log.Error("初始化数据库失败", logger.F("error", err))
runtime.LogFatal(ctx, fmt.Sprintf("初始化数据库失败: %v", err))
return
}
a.db = db
if err := db.Migrate(); err != nil {
log.Error("数据库迁移失败", logger.F("error", err))
}
a.browserMgr = browser.NewManager(cfg, a.appRoot)
a.xrayMgr = proxy.NewXrayManager(cfg, a.appRoot)
a.clashMgr = proxy.NewClashManager(cfg, a.appRoot)
a.singboxMgr = proxy.NewSingBoxManager(cfg, a.appRoot)
// 注入 DAO(必须在 InitData 之前)
conn := db.GetConn()
a.browserMgr.ProfileDAO = browser.NewSQLiteProfileDAO(conn)
a.browserMgr.ProxyDAO = browser.NewSQLiteProxyDAO(conn)
a.browserMgr.CoreDAO = browser.NewSQLiteCoreDAO(conn)
a.browserMgr.BookmarkDAO = browser.NewSQLiteBookmarkDAO(conn)
a.browserMgr.GroupDAO = browser.NewSQLiteGroupDAO(conn)
// 一次性迁移:若 SQLite 表为空则从旧文件导入
a.migrateToSQLite()
a.browserMgr.InitData()
a.autoDetectCores()
a.loadProxies()
a.reconcileProfileProxyBindings()
// 初始化 LaunchCode 服务
launchCodeDAO := launchcode.NewSQLiteLaunchCodeDAO(a.db.GetConn())
a.launchCodeSvc = launchcode.NewLaunchCodeService(launchCodeDAO)
if err := a.launchCodeSvc.LoadAll(); err != nil {
log.Error("LaunchCode 加载失败", logger.F("error", err))
}
a.browserMgr.CodeProvider = a.launchCodeSvc
// 启动 LaunchServer
port := a.config.LaunchServer.Port
a.launchServer = launchcode.NewLaunchServer(a.launchCodeSvc, a, a.browserMgr, port)
a.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{
Enabled: a.config.LaunchServer.Auth.Enabled,
APIKey: a.config.LaunchServer.Auth.APIKey,
Header: a.config.LaunchServer.Auth.Header,
})
if err := a.launchServer.Start(); err != nil {
log.Error("LaunchServer 启动失败", logger.F("error", err))
} else {
log.Info("LaunchServer 监听地址",
logger.F("url", fmt.Sprintf("http://127.0.0.1:%d", a.launchServer.Port())),
logger.F("preferred_port", port),
)
}
// 连接池失效通知
a.xrayMgr.OnBridgeDied = func(key string, err error) {
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "proxy:bridge:died", map[string]interface{}{
"engine": "xray",
"key": key[:8],
"error": err.Error(),
})
}
}
a.singboxMgr.OnBridgeDied = func(key string, err error) {
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "proxy:bridge:died", map[string]interface{}{
"engine": "singbox",
"key": key[:8],
"error": err.Error(),
})
}
}
// 启动代理测速定时调度器(每5分钟一轮,并发5)
a.speedScheduler = browser.NewProxySpeedScheduler(
a.browserMgr.ProxyDAO,
func(proxyId string) (bool, int64, string) {
r := proxy.SpeedTest(proxyId, a.config.Browser.Proxies, a.xrayMgr, a.singboxMgr, nil)
return r.Ok, r.LatencyMs, r.Error
},
5*time.Minute,
5,
)
a.speedScheduler.Start()
log.Info("应用启动成功")
}
// ReloadConfig 开放给前端重新读取配置,用于应对手动修补后的配置重载
func (a *App) ReloadConfig() error {
log := logger.New("App")
cfg, err := LoadConfig(a.resolveAppPath("config.yaml"))
if err != nil {
log.Error("重载配置文件失败", logger.F("error", err))
return fmt.Errorf("重载配置文件失败: %w", err)
}
a.config = cfg
a.applyRuntimeConfig(cfg.Runtime)
// Update browser manager config reference
if a.browserMgr != nil {
a.browserMgr.Config = cfg
a.browserMgr.ListCores()
a.loadProxies()
a.reconcileProfileProxyBindings()
}
if a.xrayMgr != nil {
a.xrayMgr.Config = cfg
}
if a.clashMgr != nil {
a.clashMgr.Config = cfg
}
if a.singboxMgr != nil {
a.singboxMgr.Config = cfg
}
if a.launchServer != nil {
a.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{
Enabled: cfg.LaunchServer.Auth.Enabled,
APIKey: cfg.LaunchServer.Auth.APIKey,
Header: cfg.LaunchServer.Auth.Header,
})
}
log.Info("前端触发配置重载成功")
return nil
}
func (a *App) applyRuntimeConfig(cfg config.RuntimeConfig) {
if cfg.GCPercent > 0 {
debug.SetGCPercent(cfg.GCPercent)
}
if cfg.MaxMemoryMB > 0 {
maxMemoryBytes := int64(cfg.MaxMemoryMB) * 1024 * 1024
debug.SetMemoryLimit(maxMemoryBytes)
return
}
// 0 表示禁用自定义软限制,避免 ReloadConfig 后残留旧的 GOMEMLIMIT。
debug.SetMemoryLimit(1 << 60)
}
func (a *App) shutdown(ctx context.Context) {
log := logger.New("App")
if a.shouldStopRuntimeServicesOnShutdown() {
log.Info("应用正在关闭...")
a.stopRuntimeServices()
} else {
log.Info("应用正在关闭(保留当前已打开的浏览器实例)...")
}
a.finalizeShutdown()
}
func (a *App) GetInterceptor() *logger.MethodInterceptor {
return a.interceptor
}
// ForceQuit 设置强制退出标志并调用 runtime.Quit
func (a *App) ForceQuit() {
a.setQuitMode(quitModeFull)
a.stopRuntimeServices()
if a.ctx != nil {
runtime.Quit(a.ctx)
}
}
// QuitAppOnly 仅退出应用本身,保留当前已打开的浏览器实例。
func (a *App) QuitAppOnly() {
a.setQuitMode(quitModeAppOnly)
if a.ctx != nil {
runtime.Quit(a.ctx)
}
}
func Start(a *App, ctx context.Context) {
a.startup(ctx)
}
func Stop(a *App, ctx context.Context) {
a.shutdown(ctx)
}
func platformSupportsTrayCloseFlow() bool {
return platformSupportsTrayCloseFlowForOS(goruntime.GOOS)
}
func platformSupportsTrayCloseFlowForOS(goos string) bool {
return strings.EqualFold(strings.TrimSpace(goos), "windows")
}
func (a *App) setQuitMode(mode quitMode) {
a.forceQuit = true
a.quitMode = mode
}
func (a *App) shouldStopRuntimeServicesOnShutdown() bool {
return a.quitMode != quitModeAppOnly
}
func ShouldBlockClose(a *App, ctx context.Context) bool {
if a.forceQuit {
return false
}
if !platformSupportsTrayCloseFlow() {
return false
}
runtime.EventsEmit(ctx, "app:request-close")
return true
}
func (a *App) bindProfileXrayBridge(profileId string, bridgeKey string) {
profileId = strings.TrimSpace(profileId)
bridgeKey = strings.TrimSpace(bridgeKey)
if profileId == "" || bridgeKey == "" {
return
}
a.bridgeMu.Lock()
a.xrayBridgeRefs[profileId] = bridgeKey
a.bridgeMu.Unlock()
}
func (a *App) releaseProfileXrayBridge(profileId string) {
profileId = strings.TrimSpace(profileId)
if profileId == "" {
return
}
a.bridgeMu.Lock()
bridgeKey := a.xrayBridgeRefs[profileId]
delete(a.xrayBridgeRefs, profileId)
a.bridgeMu.Unlock()
if bridgeKey != "" && a.xrayMgr != nil {
a.xrayMgr.ReleaseBridge(bridgeKey)
}
}
func (a *App) clearProfileXrayBridges() {
a.bridgeMu.Lock()
a.xrayBridgeRefs = make(map[string]string)
a.bridgeMu.Unlock()
}
// ============================================================================
// 仪表盘 API
// ============================================================================
func (a *App) GetDashboardStats() map[string]interface{} {
profiles := a.browserMgr.List()
totalInstances := len(profiles)
runningInstances := 0
for _, p := range profiles {
if p.Running {
runningInstances++
}
}
proxyCount := len(a.config.Browser.Proxies)
coreCount := len(a.config.Browser.Cores)
var mem goruntime.MemStats
goruntime.ReadMemStats(&mem)
memUsedMB := float64(mem.Alloc) / 1024 / 1024
return map[string]interface{}{
"totalInstances": totalInstances,
"runningInstances": runningInstances,
"proxyCount": proxyCount,
"coreCount": coreCount,
"memUsedMB": int(memUsedMB),
"appVersion": a.appVersion(),
}
}
func (a *App) GetAppConfig() map[string]interface{} {
return map[string]interface{}{
"name": a.appName(),
"version": a.appVersion(),
}
}
func (a *App) GetMemoryStats() map[string]interface{} {
var m goruntime.MemStats
goruntime.ReadMemStats(&m)
return map[string]interface{}{
"alloc_mb": float64(m.Alloc) / 1024 / 1024,
"total_alloc_mb": float64(m.TotalAlloc) / 1024 / 1024,
"sys_mb": float64(m.Sys) / 1024 / 1024,
"num_gc": m.NumGC,
"limit_mb": a.config.Runtime.MaxMemoryMB,
"gc_percent": a.config.Runtime.GCPercent,
}
}
func (a *App) TriggerGC() { goruntime.GC() }
func (a *App) SetLogLevel(level string) { logger.SetGlobalLevelString(level) }
func (a *App) GetLogLevel() string { return logger.New("App").GetLevel().String() }
// GetAppLogs 获取内存缓冲日志
func (a *App) GetAppLogs() []logger.MemoryLogEntry {
return logger.GetMemoryWriter().GetEntries()
}
// ClearAppLogs 清空内存缓冲日志
func (a *App) ClearAppLogs() {
logger.GetMemoryWriter().Clear()
}
// GetRunningInstances 获取运行中实例的详细信息
func (a *App) GetRunningInstances() []BrowserProfile {
all := a.browserMgr.List()
result := make([]BrowserProfile, 0)
for _, p := range all {
if p.Running {
result = append(result, p)
}
}
return result
}
// ============================================================================
// 浏览器类型别名 (保持 Wails 绑定兼容)
// ============================================================================
type BrowserProfile = browser.Profile
type BrowserProfileInput = browser.ProfileInput
type BrowserTab = browser.Tab
type BrowserSettings = browser.Settings
type BrowserProxy = browser.Proxy
type BrowserCore = browser.Core
type BrowserCoreInput = browser.CoreInput
type BrowserCoreValidateResult = browser.CoreValidateResult
type BrowserCoreExtendedInfo = browser.CoreExtendedInfo
// ============================================================================
// 浏览器配置 API
// ============================================================================
// BrowserProfileList 获取所有实例列表
func (a *App) BrowserProfileList() []BrowserProfile { return a.browserMgr.List() }
// BrowserProfileListByTag 按标签筛选实例列表
func (a *App) BrowserProfileListByTag(tag string) []BrowserProfile {
return a.browserMgr.ListByTag(tag)
}
// BrowserGetAllTags 获取所有已使用的标签
func (a *App) BrowserGetAllTags() []string {
return a.browserMgr.GetAllTags()
}
// BrowserProfileSetKeywords 设置实例关键字
func (a *App) BrowserProfileSetKeywords(profileId string, keywords []string) (*BrowserProfile, error) {
return a.browserMgr.SetKeywords(profileId, keywords)
}
func (a *App) BrowserProfileCreate(input BrowserProfileInput) (*BrowserProfile, error) {
return a.browserMgr.Create(input)
}
func (a *App) BrowserProfileUpdate(profileId string, input BrowserProfileInput) (*BrowserProfile, error) {
return a.browserMgr.Update(profileId, input)
}
func (a *App) BrowserProfileDelete(profileId string) error { return a.browserMgr.Delete(profileId) }
// BrowserProfileCopy 复制实例配置(除指纹参数外全部复制)
func (a *App) BrowserProfileCopy(profileId string, newName string) (*BrowserProfile, error) {
return a.browserMgr.Copy(profileId, newName)
}
// ============================================================================
// 浏览器设置 API
// ============================================================================
func (a *App) GetBrowserSettings() BrowserSettings {
return BrowserSettings{
UserDataRoot: a.config.Browser.UserDataRoot,
DefaultFingerprintArgs: append([]string{}, a.config.Browser.DefaultFingerprintArgs...),
DefaultLaunchArgs: append([]string{}, a.config.Browser.DefaultLaunchArgs...),
DefaultProxy: a.config.Browser.DefaultProxy,
StartReadyTimeoutMs: browserStartReadyTimeoutMillis(a.config),
StartStableWindowMs: browserStartStableWindowMillis(a.config),
}
}
func (a *App) SaveBrowserSettings(settings BrowserSettings) error {
log := logger.New("Browser")
a.config.Browser.UserDataRoot = strings.TrimSpace(settings.UserDataRoot)
a.config.Browser.DefaultFingerprintArgs = append([]string{}, settings.DefaultFingerprintArgs...)
a.config.Browser.DefaultLaunchArgs = append([]string{}, settings.DefaultLaunchArgs...)
a.config.Browser.DefaultProxy = strings.TrimSpace(settings.DefaultProxy)
if settings.StartReadyTimeoutMs > 0 {
a.config.Browser.StartReadyTimeoutMs = settings.StartReadyTimeoutMs
} else if a.config.Browser.StartReadyTimeoutMs <= 0 {
a.config.Browser.StartReadyTimeoutMs = browserStartReadyTimeoutMillis(nil)
}
if settings.StartStableWindowMs > 0 {
a.config.Browser.StartStableWindowMs = settings.StartStableWindowMs
} else if a.config.Browser.StartStableWindowMs <= 0 {
a.config.Browser.StartStableWindowMs = browserStartStableWindowMillis(nil)
}
if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil {
log.Error("浏览器配置保存失败", logger.F("error", err))
return err
}
return nil
}
// ============================================================================
// 内核管理 API
// ============================================================================
func (a *App) BrowserCoreList() []BrowserCore {
return a.browserMgr.ListCores()
}
func (a *App) BrowserCoreSave(input BrowserCoreInput) error {
return a.browserMgr.SaveCore(input)
}
func (a *App) BrowserCoreDelete(coreId string) error {
return a.browserMgr.DeleteCore(coreId)
}
func (a *App) BrowserCoreSetDefault(coreId string) error {
return a.browserMgr.SetDefaultCore(coreId)
}
func (a *App) BrowserCoreValidate(corePath string) BrowserCoreValidateResult {
return a.browserMgr.ValidateCorePath(corePath)
}
func (a *App) BrowserCoreExtendedInfo() []BrowserCoreExtendedInfo {
return a.browserMgr.GetCoresExtendedInfo()
}
// BrowserCoreScan 重新扫描 chrome 目录,自动注册新内核
func (a *App) BrowserCoreScan() []BrowserCore {
a.autoDetectCores()
return a.browserMgr.ListCores()
}
// BrowserCoreDownload 在线下载并自动解压配置内核
func (a *App) BrowserCoreDownload(coreName, url, proxyConfig string) error {
if a.ctx == nil {
return fmt.Errorf("app context is nil")
}
// 异步启动下载流程,以防阻塞前端请求,通过 Wails events 发送进度
go a.browserMgr.DownloadAndExtractCore(a.ctx, coreName, url, proxyConfig)
return nil
}
// ============================================================================
// 代理池 API
// ============================================================================
// ProxyValidationResult 代理验证结果
type ProxyValidationResult struct {
Supported bool `json:"supported"`
ErrorMsg string `json:"errorMsg"`
}
func (a *App) BrowserProxyList() []BrowserProxy {
if a.browserMgr.ProxyDAO != nil {
if list, err := a.browserMgr.ProxyDAO.List(); err == nil {
return list
}
}
return append([]BrowserProxy{}, a.config.Browser.Proxies...)
}
// BrowserProxyListGroups 获取所有代理分组名称
func (a *App) BrowserProxyListGroups() []string {
if a.browserMgr.ProxyDAO != nil {
if groups, err := a.browserMgr.ProxyDAO.ListGroups(); err == nil {
return groups
}
}
return nil
}
// BrowserProxyListByGroup 按分组名称查询代理
func (a *App) BrowserProxyListByGroup(groupName string) []BrowserProxy {
if a.browserMgr.ProxyDAO != nil {
if list, err := a.browserMgr.ProxyDAO.ListByGroup(groupName); err == nil {
return list
}
}
// 降级:内存过滤
var result []BrowserProxy
for _, p := range a.config.Browser.Proxies {
if p.GroupName == groupName {
result = append(result, p)
}
}
return result
}
// ValidateProxyConfig 验证代理配置是否支持
func (a *App) ValidateProxyConfig(proxyConfig string, proxyId string) ProxyValidationResult {
proxies := a.getLatestProxies()
supported, errorMsg := proxy.ValidateProxyConfig(proxyConfig, proxies, proxyId)
return ProxyValidationResult{
Supported: supported,
ErrorMsg: errorMsg,
}
}
// ProxyTestResult 代理测试结果
type ProxyTestResult struct {
ProxyId string `json:"proxyId"`
Ok bool `json:"ok"`
LatencyMs int64 `json:"latencyMs"`
Error string `json:"error"`
}
// ProxyIPHealthResult 代理出口 IP 健康信息(透传第三方接口结果)
type ProxyIPHealthResult struct {
ProxyId string `json:"proxyId"`
Ok bool `json:"ok"`
Source string `json:"source"`
Error string `json:"error"`
IP string `json:"ip"`
FraudScore int64 `json:"fraudScore"`
IsResidential bool `json:"isResidential"`
IsBroadcast bool `json:"isBroadcast"`
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
AsOrganization string `json:"asOrganization"`
RawData map[string]interface{} `json:"rawData"`
UpdatedAt string `json:"updatedAt"`
}
// TestProxyConnectivity 测试代理连通性
func (a *App) TestProxyConnectivity(proxyId string, proxyConfig string) ProxyTestResult {
proxies := a.getLatestProxies()
r := proxy.TestConnectivity(proxyId, proxyConfig, proxies, nil)
return ProxyTestResult{ProxyId: r.ProxyId, Ok: r.Ok, LatencyMs: r.LatencyMs, Error: r.Error}
}
// TestProxyRealConnectivity 通过真实 HTTP 请求测试代理连通性(Wails 绑定)
// 参考 Clash URLTest 策略:多 URL fallback + 复用桥接 + TCP ping 降级
func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult {
proxies := a.getLatestProxies()
r := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, nil)
return ProxyTestResult{ProxyId: r.ProxyId, Ok: r.Ok, LatencyMs: r.LatencyMs, Error: r.Error}
}
// BrowserProxyTestSpeed 手动触发单个代理测速并持久化结果
func (a *App) BrowserProxyTestSpeed(proxyId string) ProxyTestResult {
proxies := a.getLatestProxies()
r := proxy.SpeedTest(proxyId, proxies, a.xrayMgr, a.singboxMgr, nil)
if a.browserMgr.ProxyDAO != nil {
testedAt := time.Now().Format(time.RFC3339)
_ = a.browserMgr.ProxyDAO.UpdateSpeedResult(proxyId, r.Ok, r.LatencyMs, testedAt)
}
return ProxyTestResult{ProxyId: r.ProxyId, Ok: r.Ok, LatencyMs: r.LatencyMs, Error: r.Error}
}
// BrowserProxyBatchTestSpeed 批量并发测速,concurrency 控制并发数(默认 20
func (a *App) BrowserProxyBatchTestSpeed(proxyIds []string, concurrency int) []ProxyTestResult {
if len(proxyIds) == 0 {
return []ProxyTestResult{}
}
if concurrency <= 0 {
concurrency = 20
}
if concurrency > len(proxyIds) {
concurrency = len(proxyIds)
}
proxies := a.getLatestProxies()
results := make([]ProxyTestResult, len(proxyIds))
type speedJob struct {
Idx int
ProxyId string
}
jobs := make(chan speedJob, len(proxyIds))
var wg sync.WaitGroup
// 固定大小 worker 池,避免大量代理时创建过多 goroutine
for worker := 0; worker < concurrency; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
r := proxy.SpeedTest(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr, nil)
if a.browserMgr.ProxyDAO != nil {
testedAt := time.Now().Format(time.RFC3339)
_ = a.browserMgr.ProxyDAO.UpdateSpeedResult(job.ProxyId, r.Ok, r.LatencyMs, testedAt)
}
result := ProxyTestResult{ProxyId: r.ProxyId, Ok: r.Ok, LatencyMs: r.LatencyMs, Error: r.Error}
results[job.Idx] = result
// 实时推送单个结果到前端
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "proxy:speed:result", result)
}
}
}()
}
for i, pid := range proxyIds {
jobs <- speedJob{Idx: i, ProxyId: pid}
}
close(jobs)
wg.Wait()
return results
}
// BrowserProxyCheckIPHealth 检测单个代理的出口 IP 健康信息(通过 IPPure 接口)
func (a *App) BrowserProxyCheckIPHealth(proxyId string) ProxyIPHealthResult {
proxies := a.getLatestProxies()
data, err := proxy.FetchIPPureInfo(proxyId, proxies, a.xrayMgr, a.singboxMgr)
result := buildProxyIPHealthResult(proxyId, data, err)
a.persistProxyIPHealthResult(result)
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "proxy:iphealth:result", result)
}
return result
}
// BrowserProxyBatchCheckIPHealth 批量并发检测代理出口 IP 健康信息
func (a *App) BrowserProxyBatchCheckIPHealth(proxyIds []string, concurrency int) []ProxyIPHealthResult {
if len(proxyIds) == 0 {
return []ProxyIPHealthResult{}
}
if concurrency <= 0 {
concurrency = 10
}
if concurrency > len(proxyIds) {
concurrency = len(proxyIds)
}
proxies := a.getLatestProxies()
results := make([]ProxyIPHealthResult, len(proxyIds))
type healthJob struct {
Idx int
ProxyId string
}
jobs := make(chan healthJob, len(proxyIds))
var wg sync.WaitGroup
for worker := 0; worker < concurrency; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
data, err := proxy.FetchIPPureInfo(job.ProxyId, proxies, a.xrayMgr, a.singboxMgr)
result := buildProxyIPHealthResult(job.ProxyId, data, err)
a.persistProxyIPHealthResult(result)
results[job.Idx] = result
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "proxy:iphealth:result", result)
}
}
}()
}
for i, pid := range proxyIds {
jobs <- healthJob{Idx: i, ProxyId: pid}
}
close(jobs)
wg.Wait()
return results
}
func buildProxyIPHealthResult(proxyId string, data map[string]interface{}, err error) ProxyIPHealthResult {
if err != nil {
return ProxyIPHealthResult{
ProxyId: proxyId,
Ok: false,
Source: "ippure",
Error: err.Error(),
RawData: map[string]interface{}{},
UpdatedAt: time.Now().Format(time.RFC3339),
}
}
if data == nil {
data = map[string]interface{}{}
}
return ProxyIPHealthResult{
ProxyId: proxyId,
Ok: true,
Source: "ippure",
Error: "",
IP: mapString(data, "ip"),
FraudScore: mapInt64(data, "fraudScore"),
IsResidential: mapBool(data, "isResidential"),
IsBroadcast: mapBool(data, "isBroadcast"),
Country: mapString(data, "country"),
Region: mapString(data, "region"),
City: mapString(data, "city"),
AsOrganization: mapString(data, "asOrganization"),
RawData: data,
UpdatedAt: time.Now().Format(time.RFC3339),
}
}
func (a *App) persistProxyIPHealthResult(result ProxyIPHealthResult) {
if a.browserMgr.ProxyDAO == nil {
return
}
payload, err := json.Marshal(result)
if err != nil {
return
}
_ = a.browserMgr.ProxyDAO.UpdateIPHealthResult(result.ProxyId, string(payload))
}
func mapString(m map[string]interface{}, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch s := v.(type) {
case string:
return s
default:
return fmt.Sprint(v)
}
}
func mapInt64(m map[string]interface{}, key string) int64 {
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch n := v.(type) {
case int:
return int64(n)
case int8:
return int64(n)
case int16:
return int64(n)
case int32:
return int64(n)
case int64:
return n
case uint:
return int64(n)
case uint8:
return int64(n)
case uint16:
return int64(n)
case uint32:
return int64(n)
case uint64:
return int64(n)
case float32:
return int64(n)
case float64:
return int64(n)
case json.Number:
if iv, err := n.Int64(); err == nil {
return iv
}
if fv, err := n.Float64(); err == nil {
return int64(fv)
}
case string:
if iv, err := strconv.ParseInt(n, 10, 64); err == nil {
return iv
}
if fv, err := strconv.ParseFloat(n, 64); err == nil {
return int64(fv)
}
}
return 0
}
func mapBool(m map[string]interface{}, key string) bool {
v, ok := m[key]
if !ok || v == nil {
return false
}
switch b := v.(type) {
case bool:
return b
case string:
return strings.EqualFold(b, "true") || b == "1"
case int:
return b != 0
case int64:
return b != 0
case float64:
return b != 0
}
return false
}
// getLatestProxies 获取最新的代理列表,优先从数据库读取
func (a *App) getLatestProxies() []BrowserProxy {
if a.browserMgr.ProxyDAO != nil {
if list, err := a.browserMgr.ProxyDAO.List(); err == nil && len(list) > 0 {
return list
}
}
return a.config.Browser.Proxies
}
func (a *App) SaveBrowserProxies(proxies []BrowserProxy) error {
log := logger.New("Browser")
normalized := make([]BrowserProxy, 0, len(proxies))
for i, item := range proxies {
proxyName := strings.TrimSpace(item.ProxyName)
proxyConfig := strings.TrimSpace(item.ProxyConfig)
if proxyName == "" || proxyConfig == "" {
continue
}
proxyId := strings.TrimSpace(item.ProxyId)
if proxyId == "" {
proxyId = generateUUID()
}
sourceURL := strings.TrimSpace(item.SourceURL)
sourceID := strings.TrimSpace(item.SourceID)
sourceNamePrefix := strings.TrimSpace(item.SourceNamePrefix)
sourceLastRefreshAt := strings.TrimSpace(item.SourceLastRefreshAt)
sourceRefreshIntervalM := item.SourceRefreshIntervalM
if sourceRefreshIntervalM < 0 {
sourceRefreshIntervalM = 0
}
if sourceRefreshIntervalM > 24*60 {
sourceRefreshIntervalM = 24 * 60
}
sourceAutoRefresh := item.SourceAutoRefresh && sourceURL != ""
if sourceAutoRefresh && sourceRefreshIntervalM <= 0 {
sourceRefreshIntervalM = 60
}
if !sourceAutoRefresh {
sourceRefreshIntervalM = 0
}
if sourceURL == "" {
sourceID = ""
sourceNamePrefix = ""
sourceLastRefreshAt = ""
sourceAutoRefresh = false
sourceRefreshIntervalM = 0
}
normalized = append(normalized, BrowserProxy{
ProxyId: proxyId,
ProxyName: proxyName,
ProxyConfig: proxyConfig,
DnsServers: strings.TrimSpace(item.DnsServers),
GroupName: strings.TrimSpace(item.GroupName),
SourceID: sourceID,
SourceURL: sourceURL,
SourceNamePrefix: sourceNamePrefix,
SourceAutoRefresh: sourceAutoRefresh,
SourceRefreshIntervalM: sourceRefreshIntervalM,
SourceLastRefreshAt: sourceLastRefreshAt,
SortOrder: i,
})
}
// 确保内置代理始终存在(直连 + 本地代理)
builtins := []BrowserProxy{
{ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"},
{ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"},
}
for _, b := range builtins {
found := false
for _, p := range normalized {
if p.ProxyId == b.ProxyId {
found = true
break
}
}
if !found {
normalized = append([]BrowserProxy{b}, normalized...)
}
}
a.config.Browser.Proxies = normalized
// 优先写入 SQLite
if a.browserMgr.ProxyDAO != nil {
if err := a.browserMgr.ProxyDAO.DeleteAll(); err != nil {
log.Error("清空代理表失败", logger.F("error", err))
return err
}
for _, p := range normalized {
if err := a.browserMgr.ProxyDAO.Upsert(p); err != nil {
log.Error("代理保存失败", logger.F("proxy_id", p.ProxyId), logger.F("error", err))
return err
}
}
log.Info("代理列表已保存到数据库", logger.F("count", len(normalized)))
a.reconcileProfileProxyBindings()
return nil
}
// 降级:写入 proxies.yaml
if err := config.SaveProxies(a.resolveAppPath("proxies.yaml"), normalized); err != nil {
log.Error("代理列表保存失败", logger.F("error", err))
return err
}
a.reconcileProfileProxyBindings()
return nil
}
// ============================================================================
// 文件系统 API
// ============================================================================
// OpenUserDataDir 在资源管理器中打开用户数据目录
func (a *App) OpenUserDataDir(userDataDir string) error {
log := logger.New("Browser")
// 解析完整路径
userDataDir = strings.TrimSpace(userDataDir)
if userDataDir == "" {
return fmt.Errorf("用户数据目录不能为空")
}
var fullPath string
if filepath.IsAbs(userDataDir) {
fullPath = userDataDir
} else {
root := strings.TrimSpace(a.config.Browser.UserDataRoot)
if root == "" {
root = "data"
}
root = a.resolveAppPath(root)
fullPath = filepath.Join(root, userDataDir)
}
// 检查目录是否存在
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
// 目录不存在,尝试创建
if err := os.MkdirAll(fullPath, 0755); err != nil {
log.Error("创建用户数据目录失败", logger.F("path", fullPath), logger.F("error", err))
return fmt.Errorf("创建目录失败: %v", err)
}
}
// 获取绝对路径
absPath, err := filepath.Abs(fullPath)
if err != nil {
log.Error("获取绝对路径失败", logger.F("path", fullPath), logger.F("error", err))
return err
}
if err := openPathInFileManager(absPath); err != nil {
log.Error("打开资源管理器失败", logger.F("path", absPath), logger.F("error", err))
return err
}
log.Info("已打开用户数据目录", logger.F("path", absPath))
return nil
}
// OpenCorePath 在资源管理器中打开内核路径
func (a *App) OpenCorePath(corePath string) error {
log := logger.New("Browser")
corePath = strings.TrimSpace(corePath)
if corePath == "" {
return fmt.Errorf("内核路径不能为空")
}
var fullPath string
if filepath.IsAbs(corePath) {
fullPath = corePath
} else {
fullPath = a.resolveAppPath(corePath)
}
// 检查目录是否存在
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return fmt.Errorf("路径不存在: %s", fullPath)
}
// 获取绝对路径
absPath, err := filepath.Abs(fullPath)
if err != nil {
log.Error("获取绝对路径失败", logger.F("path", fullPath), logger.F("error", err))
return err
}
if err := openPathInFileManager(absPath); err != nil {
log.Error("打开资源管理器失败", logger.F("path", absPath), logger.F("error", err))
return err
}
log.Info("已打开内核路径", logger.F("path", absPath))
return nil
}
// openPathInFileManager 调用系统文件管理器打开路径。
// Windows 下不能复用 hideWindow,否则可能导致资源管理器窗口被隐藏。
func openPathInFileManager(absPath string) error {
info, err := os.Stat(absPath)
if err != nil {
return err
}
switch goruntime.GOOS {
case "windows":
if info.IsDir() {
return exec.Command("explorer.exe", absPath).Start()
}
return exec.Command("explorer.exe", "/select,", absPath).Start()
case "darwin":
if info.IsDir() {
return exec.Command("open", absPath).Start()
}
return exec.Command("open", "-R", absPath).Start()
default:
target := absPath
if !info.IsDir() {
target = filepath.Dir(absPath)
}
return exec.Command("xdg-open", target).Start()
}
}
// ============================================================================
// 数据迁移
// ============================================================================
// migrateToSQLite 一次性迁移:若 SQLite 表为空则从旧文件导入数据,或初始化默认数据
// 迁移顺序:cores → proxies → profiles → bookmarks
func (a *App) migrateToSQLite() {
log := logger.New("Migration")
// 迁移/初始化内核
if cores, err := a.browserMgr.CoreDAO.List(); err == nil && len(cores) == 0 {
// 优先从 config.yaml 迁移
if len(a.config.Browser.Cores) > 0 {
for _, c := range a.config.Browser.Cores {
if err := a.browserMgr.CoreDAO.Upsert(c); err != nil {
log.Error("内核迁移失败", logger.F("core_id", c.CoreId), logger.F("error", err))
}
}
log.Info("内核数据已迁移", logger.F("count", len(a.config.Browser.Cores)))
} else {
// 初始化默认内核(自动检测会补充)
log.Info("内核表为空,将通过自动检测初始化")
}
}
// 迁移/初始化代理
if proxies, err := a.browserMgr.ProxyDAO.List(); err == nil && len(proxies) == 0 {
var srcProxies []browser.Proxy
// 优先 proxies.yaml,其次 config.yaml
if loaded, err := config.LoadProxies(a.resolveAppPath("proxies.yaml")); err == nil && len(loaded) > 0 {
srcProxies = loaded
} else if len(a.config.Browser.Proxies) > 0 {
srcProxies = a.config.Browser.Proxies
} else {
// 初始化默认代理
srcProxies = []browser.Proxy{
{ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"},
{ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"},
}
log.Info("代理表为空,初始化默认代理")
}
for _, p := range srcProxies {
if err := a.browserMgr.ProxyDAO.Upsert(p); err != nil {
log.Error("代理迁移失败", logger.F("proxy_id", p.ProxyId), logger.F("error", err))
}
}
if len(srcProxies) > 0 {
log.Info("代理数据已初始化", logger.F("count", len(srcProxies)))
}
}
// 迁移实例配置(如果为空则自动创建一个默认实例)
if profiles, err := a.browserMgr.ProfileDAO.List(); err == nil && len(profiles) == 0 {
if len(a.config.Browser.Profiles) > 0 {
for _, pc := range a.config.Browser.Profiles {
coreId := strings.TrimSpace(pc.CoreId)
if strings.EqualFold(coreId, "default") {
coreId = ""
}
p := &browser.Profile{
ProfileId: pc.ProfileId,
ProfileName: pc.ProfileName,
UserDataDir: pc.UserDataDir,
CoreId: coreId,
FingerprintArgs: pc.FingerprintArgs,
ProxyId: pc.ProxyId,
ProxyConfig: pc.ProxyConfig,
ProxyBindSourceID: pc.ProxyBindSourceID,
ProxyBindSourceURL: pc.ProxyBindSourceURL,
ProxyBindName: pc.ProxyBindName,
ProxyBindUpdatedAt: pc.ProxyBindUpdatedAt,
LaunchArgs: pc.LaunchArgs,
Tags: pc.Tags,
Keywords: pc.Keywords,
CreatedAt: pc.CreatedAt,
UpdatedAt: pc.UpdatedAt,
}
if err := a.browserMgr.ProfileDAO.Upsert(p); err != nil {
log.Error("实例迁移失败", logger.F("profile_id", pc.ProfileId), logger.F("error", err))
}
}
log.Info("实例数据已迁移", logger.F("count", len(a.config.Browser.Profiles)))
} else {
log.Info("实例表为空,自动创建默认实例")
defaultProfile := &browser.Profile{
ProfileId: generateUUID(),
ProfileName: "默认实例",
UserDataDir: "default",
CoreId: "",
FingerprintArgs: a.config.Browser.DefaultFingerprintArgs,
LaunchArgs: a.config.Browser.DefaultLaunchArgs,
Tags: []string{"默认"},
ProxyId: a.config.Browser.DefaultProxy,
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
if err := a.browserMgr.ProfileDAO.Upsert(defaultProfile); err != nil {
log.Error("自动创建默认实例失败", logger.F("error", err))
}
}
}
// 迁移/初始化书签
if bookmarks, err := a.browserMgr.BookmarkDAO.List(); err == nil && len(bookmarks) == 0 {
src := a.config.Browser.DefaultBookmarks
if len(src) == 0 {
// 初始化默认书签
src = []config.BrowserBookmark{
{Name: "Google", URL: "https://www.google.com/"},
{Name: "Gmail", URL: "https://mail.google.com/"},
{Name: "Claude", URL: "https://claude.ai/"},
{Name: "ChatGPT", URL: "https://chatgpt.com/"},
{Name: "YouTube", URL: "https://www.youtube.com/"},
}
}
if err := a.browserMgr.BookmarkDAO.ReplaceAll(src); err != nil {
log.Error("书签迁移失败", logger.F("error", err))
} else {
log.Info("书签数据已迁移", logger.F("count", len(src)))
}
}
}