mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
channel: master version: 1.0.0 source-ref: master published-at-utc: 2026-03-13T15:19:28Z
1169 lines
35 KiB
Go
1169 lines
35 KiB
Go
package backend
|
||
|
||
import (
|
||
"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"
|
||
)
|
||
|
||
// 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
|
||
|
||
forceQuit bool // 强制退出标志,用于跳过 OnBeforeClose 的拦截
|
||
maintenanceMu sync.Mutex // 维护类操作(初始化/导入/导出)互斥锁
|
||
bridgeMu sync.Mutex
|
||
xrayBridgeRefs map[string]string
|
||
stopServicesOnce sync.Once
|
||
finalizeOnce sync.Once
|
||
}
|
||
|
||
// NewApp 创建新的应用实例
|
||
func NewApp(appRoot string) *App {
|
||
return &App{
|
||
appRoot: strings.TrimSpace(appRoot),
|
||
xrayBridgeRefs: make(map[string]string),
|
||
}
|
||
}
|
||
|
||
// startup 应用启动时调用
|
||
func (a *App) startup(ctx context.Context) {
|
||
a.ctx = ctx
|
||
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", "1.0.0"),
|
||
logger.F("max_memory_mb", cfg.Runtime.MaxMemoryMB),
|
||
logger.F("gc_percent", cfg.Runtime.GCPercent),
|
||
)
|
||
|
||
// 确保 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()
|
||
|
||
// 初始化 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)
|
||
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{}{
|
||
"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.loadProxies()
|
||
}
|
||
if a.xrayMgr != nil {
|
||
a.xrayMgr.Config = cfg
|
||
}
|
||
if a.clashMgr != nil {
|
||
a.clashMgr.Config = cfg
|
||
}
|
||
if a.singboxMgr != nil {
|
||
a.singboxMgr.Config = cfg
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
func (a *App) shutdown(ctx context.Context) {
|
||
log := logger.New("App")
|
||
log.Info("应用正在关闭...")
|
||
a.stopRuntimeServices()
|
||
a.finalizeShutdown()
|
||
}
|
||
|
||
func (a *App) GetInterceptor() *logger.MethodInterceptor {
|
||
return a.interceptor
|
||
}
|
||
|
||
// ForceQuit 设置强制退出标志并调用 runtime.Quit
|
||
func (a *App) ForceQuit() {
|
||
a.forceQuit = true
|
||
a.stopRuntimeServices()
|
||
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 ShouldBlockClose(a *App, ctx context.Context) bool {
|
||
if a.forceQuit {
|
||
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),
|
||
}
|
||
}
|
||
|
||
func (a *App) GetAppConfig() map[string]interface{} {
|
||
return map[string]interface{}{"name": a.config.App.Name, "version": "1.0.0"}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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 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)))
|
||
return nil
|
||
}
|
||
|
||
// 降级:写入 proxies.yaml
|
||
if err := config.SaveProxies(a.resolveAppPath("proxies.yaml"), normalized); err != nil {
|
||
log.Error("代理列表保存失败", logger.F("error", err))
|
||
return err
|
||
}
|
||
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 {
|
||
p := &browser.Profile{
|
||
ProfileId: pc.ProfileId,
|
||
ProfileName: pc.ProfileName,
|
||
UserDataDir: pc.UserDataDir,
|
||
CoreId: pc.CoreId,
|
||
FingerprintArgs: pc.FingerprintArgs,
|
||
ProxyId: pc.ProxyId,
|
||
ProxyConfig: pc.ProxyConfig,
|
||
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: "default",
|
||
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)))
|
||
}
|
||
}
|
||
}
|